Compare commits

..

9 Commits

64 changed files with 1832 additions and 217 deletions

6
.gitignore vendored
View File

@@ -8,3 +8,9 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
# Terraform/Tofu
examples/tofu/**/.terraform
examples/tofu/**/.terraform.lock.hcl
examples/tofu/**/.terraform.tfstate
examples/tofu/**/.terraform.tfstate.backup

View File

@@ -1 +1,7 @@
Test # Private Compute Stack (Pieces)
Pieces is a mostly API-compatible implementation of AWS services.
## EC2
A very small subset of EC2 functionality is implemented.

View File

@@ -6,3 +6,5 @@ users:
groups: wheel groups: wheel
plain_text_passwd: abc123 plain_text_passwd: abc123
sudo: ["ALL=(ALL) NOPASSWD:ALL"] sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDUz3WF4qPhk01//5QUuNWyHTn8shv86i/qEyRqa1kTF alexander@miku

61
examples/tofu/main.tf Normal file
View File

@@ -0,0 +1,61 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
# https://geo.mirror.pkgbuild.com/images/v20250315.322357/Arch-Linux-x86_64-cloudimg.qcow2
# Import using:
# aws ec2 import-image --disk-container "Url=https://geo.mirror.pkgbuild.com/images/v20250315.322357/Arch-Linux-x86_64-cloudimg.qcow2" --tag-specification 'Tags=[{Key="Linux",Value="ArchLinux-nocloud"}]'
data "aws_ami" "archlinux-nocloud" {
filter {
name = "tag:Linux"
values = ["ArchLinux-nocloud"]
}
}
resource "aws_instance" "test-instance-1" {
ami = data.aws_ami.archlinux-nocloud.id
instance_type = "micro"
availability_zone = "az-1"
private_ip = "192.168.122.3"
user_data = file("cloudinit.yaml")
tags = {
UseCase = "k8s-control-plane"
}
}
# resource "aws_instance" "test-instance-2" {
# ami = "0c4dcaafb6a14dbb93b402f1fd6a9dfb"
# instance_type = "micro"
# availability_zone = "az-1"
# private_ip = "192.168.122.4"
# tags = {
# UseCase = "k8s-control-plane"
# }
# }
# resource "aws_instance" "test-instance-3" {
# ami = "0c4dcaafb6a14dbb93b402f1fd6a9dfb"
# instance_type = "micro"
# availability_zone = "az-1"
# private_ip = "192.168.122.5"
# tags = {
# UseCase = "k8s-control-plane"
# }
# }

View File

@@ -0,0 +1 @@
{"version":4,"terraform_version":"1.9.0","serial":8,"lineage":"a013da38-6954-7573-33dd-c05f6b0ec61f","outputs":{},"resources":[{"mode":"managed","type":"aws_instance","name":"test-instance","provider":"provider[\"registry.opentofu.org/hashicorp/aws\"]","instances":[{"schema_version":1,"attributes":{"ami":"0c4dcaafb6a14dbb93b402f1fd6a9dfb","arn":"arn:aws:ec2:eu-west-1:1:instance/19b606d3c0b543c991f2b0cd632013a2","associate_public_ip_address":false,"availability_zone":"az-1","capacity_reservation_specification":[],"cpu_core_count":null,"cpu_options":[],"cpu_threads_per_core":null,"credit_specification":[],"disable_api_stop":false,"disable_api_termination":false,"ebs_block_device":[],"ebs_optimized":false,"enable_primary_ipv6":null,"enclave_options":[],"ephemeral_block_device":[],"get_password_data":false,"hibernation":null,"host_id":null,"host_resource_group_arn":null,"iam_instance_profile":"","id":"19b606d3c0b543c991f2b0cd632013a2","instance_initiated_shutdown_behavior":null,"instance_lifecycle":"","instance_market_options":[],"instance_state":"running","instance_type":"","ipv6_address_count":0,"ipv6_addresses":[],"key_name":"","launch_template":[],"maintenance_options":[],"metadata_options":[],"monitoring":null,"network_interface":[],"outpost_arn":"","password_data":"","placement_group":null,"placement_partition_number":null,"primary_network_interface_id":"","private_dns":"","private_dns_name_options":[],"private_ip":"","public_dns":"","public_ip":"","root_block_device":[],"secondary_private_ips":[],"security_groups":[],"source_dest_check":true,"spot_instance_request_id":"","subnet_id":"","tags":{"TestTag":"TestValue"},"tags_all":{"TestTag":"TestValue"},"tenancy":null,"timeouts":null,"user_data":null,"user_data_base64":null,"user_data_replace_on_change":false,"volume_tags":null,"vpc_security_group_ids":[]},"sensitive_attributes":[],"private":"eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6MTIwMDAwMDAwMDAwMCwicmVhZCI6OTAwMDAwMDAwMDAwLCJ1cGRhdGUiOjYwMDAwMDAwMDAwMH0sInNjaGVtYV92ZXJzaW9uIjoiMSJ9"}]}],"check_results":null}

View File

@@ -0,0 +1 @@
{"version":4,"terraform_version":"1.9.0","serial":7,"lineage":"a013da38-6954-7573-33dd-c05f6b0ec61f","outputs":{},"resources":[],"check_results":null}

View File

@@ -6,6 +6,7 @@ readme = "README.md"
authors = [] authors = []
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"cryptography>=44.0.2",
"fastapi[standard]>=0.115.12", "fastapi[standard]>=0.115.12",
"libvirt-python>=11.1.0", "libvirt-python>=11.1.0",
"pydantic-xml>=2.14.3", "pydantic-xml>=2.14.3",
@@ -20,3 +21,8 @@ openec2 = "openec2:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[dependency-groups]
dev = [
"ruff>=0.11.2",
]

View File

@@ -0,0 +1,10 @@
#!/bin/bash
ifId=$1
cidr=$2
broadcast=$3
ip link add name "$ifId" type bridge
ip addr add "$cidr" dev "$ifId" broadcast "$broadcast"
ip link set dev "$ifId" up
# TODO: NAT

View File

@@ -0,0 +1,90 @@
import uuid
import xml.etree.ElementTree as ET
from fastapi import Response, HTTPException
from fastapi.datastructures import QueryParams
from sqlmodel import select
import libvirt
from openec2.api.attach_volume import AttachVolumeResponse
from openec2.libvirt import LibvirtSingleton
from openec2.utils.libvirt import instance_to_libvirt_xml
from openec2.db.user import User
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.instance import Instance, EBSVolume
from openec2.utils.libvirt import ebs_volume_to_libvirt_xml
def attach_volume(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
device = params["Device"]
instance_id = params["InstanceId"]
volume_id = params["VolumeId"]
volume = db.exec(select(EBSVolume).where(EBSVolume.id == volume_id, Instance.owner_id == user.id)).first()
if volume is None:
return
instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.owner_id == user.id, Instance.terminated == False)).first()
if instance is None:
return
attached_volume_ids = [i.id for i in instance.ebs_volumes]
if volume_id in attached_volume_ids:
print("CANNOT ATTACH THE SAME VOLUME TO THE EC2 TWICE")
return
if not volume.multi_attach_enabled and volume.instances:
print("CANNOT ATTACH NON-MULTIATTACH again")
return
# Add the required data to libvirt
conn = LibvirtSingleton.of().connection
domain = conn.lookupByName(instance_id)
# Add the memory backing if required
running = domain.isActive()
if not instance.ebs_volumes:
if running:
raise HTTPException(
status_code=500,
detail="Instance is running",
)
# Update the instance
volume.instances.append(instance)
domain_xml = domain.XMLDesc()
domain_uuid = ET.fromstring(domain_xml).find("uuid").text
print(f"Updating XML for {instance.id} with {domain_uuid}")
instance_xml = instance_to_libvirt_xml(instance, config, domain_uuid)
print(instance_xml)
conn.defineXML(instance_xml)
else:
# Attach the device
volume.instances.append(instance)
domain.attachDeviceFlags(
ebs_volume_to_libvirt_xml(volume, config),
libvirt.VIR_DOMAIN_DEVICE_MODIFY_LIVE
if running
else libvirt.VIR_DOMAIN_DEVICE_MODIFY_CONFIG,
)
db.add(volume)
db.commit()
return Response(
AttachVolumeResponse(
requestId=uuid.uuid4().hex,
volumeId=volume_id,
instanceId=instance_id,
device=device,
status="attached",
).to_xml(),
media_type="application/xml",
)

View File

@@ -0,0 +1,51 @@
from dataclasses import dataclass
from typing import cast
import uuid
from fastapi import Response
from fastapi.datastructures import QueryParams
from sqlmodel import select
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.instance import Instance
from openec2.utils.array import parse_array_objects, parse_array_plain
from openec2.api.create_tags import CreateTagsResponse
@dataclass
class Tag:
key: str
value: str
def create_tags(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
tags: dict[str, str] = {}
for tag in parse_array_objects("Tag", cast(dict, params)):
tags[tag["Key"]] = tag["Value"]
for instance_id in parse_array_plain("ResourceId", cast(dict, params)):
instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.owner_id == user.id, Instance.terminated == False)).first()
if instance is None:
print(f"Unknown instance {instance_id}")
continue
instance.tags = {
**instance.tags,
**tags,
}
print(instance)
db.commit()
return Response(
CreateTagsResponse(
requestId=uuid.uuid4().hex,
return_=True,
).to_xml(),
media_type="application/xml",
)

View File

@@ -0,0 +1,41 @@
import uuid
from fastapi import Response
from fastapi.datastructures import QueryParams
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.instance import EBSVolume
from openec2.api.create_volume import CreateVolumeResponse
def create_volume(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
availabilityZone = params["AvailabilityZone"]
volume_id = f"vol-{uuid.uuid4().hex}"
volume = EBSVolume(
id=volume_id,
availability_zone=availabilityZone,
multi_attach_enabled=params.get("MultiAttachEnabled", "false") == "true",
owner_id=user.id,
)
volume.path(config).mkdir()
db.add(volume)
db.commit()
return Response(
CreateVolumeResponse(
requestId=uuid.uuid4().hex,
volumeId=volume_id,
availabilityZone=volume.availability_zone,
multiAttachEnabled=volume.multi_attach_enabled,
).to_xml(),
media_type="application/xml",
)

View File

@@ -0,0 +1,33 @@
from fastapi import Response, HTTPException
from fastapi.datastructures import QueryParams
from sqlmodel import select
from openec2.db.user import User
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.vpc import VPC
from openec2.network.vpc import prepare_host_vpc
def create_vpc(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
# TODO: Check if it already exists
cidr_block = params["CidrBlock"]
vpcs = db.exec(select(VPC)).all()
max_interface_num = max(v.bridge_num for v in vpcs) if vpcs else 0
# Create the VPC
vpc = VPC(
bridge_num=max_interface_num + 1,
cidr=cidr_block,
owner_id=user.id,
default=False,
)
prepare_host_vpc(vpc)
db.add(vpc)
db.commit()

View File

@@ -4,19 +4,26 @@ from sqlmodel import select
from openec2.config import OpenEC2Config from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.image import AMI from openec2.db.image import AMI
from openec2.images import garbage_collect_image from openec2.images import garbage_collect_image
def deregister_image( def deregister_image(
params: QueryParams, params: QueryParams,
config: OpenEC2Config, config: OpenEC2Config,
db: DatabaseDep, db: DatabaseDep,
user: User,
): ):
image_id = params["ImageId"] image_id = params["ImageId"]
ami = db.exec(select(AMI).where(AMI.id == image_id)).one() ami = db.exec(select(AMI).where(AMI.id == image_id)).one()
if ami is None: if ami is None:
raise HTTPException(status_code=404, detail="Unknown AMI") raise HTTPException(status_code=404, detail="Unknown AMI")
# Check if the requester can deregister the image.
if ami.owner_id != user.id:
raise HTTPException(status_code=403)
# Mark the image as deregistered # Mark the image as deregistered
ami.deregistered = True ami.deregistered = True
db.add(ami) db.add(ami)

View File

@@ -1,4 +1,6 @@
import uuid import uuid
from typing import cast
from dataclasses import dataclass
from fastapi import Response from fastapi import Response
from fastapi.datastructures import QueryParams from fastapi.datastructures import QueryParams
@@ -6,31 +8,66 @@ from sqlmodel import select
from openec2.config import OpenEC2Config from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.image import AMI from openec2.db.image import AMI
from openec2.api.describe_images import DescribeImagesResponse, ImagesSet, Image from openec2.api.describe_images import DescribeImagesResponse, Image
from openec2.api.shared import Tag
from openec2.utils.array import parse_array_objects, parse_array_plain
@dataclass
class Filter:
name: str
values: list[str]
def match(self, image: AMI) -> bool:
value: str | None
if self.name.startswith("tag:"):
value = image.tags.get(self.name.replace("tag:", ""))
else:
raise Exception(f"Unknown filter name {self.name}")
return value in self.values
def describe_images( def describe_images(
params: QueryParams, params: QueryParams,
config: OpenEC2Config, config: OpenEC2Config,
db: DatabaseDep, db: DatabaseDep,
user: User,
): ):
filters: list[Filter] = []
for filter in parse_array_objects("Filter", cast(dict, params)):
filters.append(
Filter(
name=filter["Name"],
values=parse_array_plain("Value", filter),
)
)
images: list[Image] = [] images: list[Image] = []
for ami in db.exec(select(AMI)).all(): for ami in db.exec(select(AMI).where(AMI.owner_id == user.id)).all():
if not all(f.match(ami) for f in filters):
continue
images.append( images.append(
Image( Image(
imageId=ami.id, imageId=ami.id,
imageState="available", imageState="available",
name=ami.originalFilename, name=ami.originalFilename,
tagSet=[
Tag(
key=key,
value=value,
) for key, value in ami.tags.items()
],
), ),
) )
return Response( return Response(
DescribeImagesResponse( DescribeImagesResponse(
requestId=uuid.uuid4().hex, requestId=uuid.uuid4().hex,
imagesSet=ImagesSet( imagesSet=images,
items=images,
)
).to_xml(), ).to_xml(),
media_type="application/xml", media_type="application/xml",
) )

View File

@@ -0,0 +1,43 @@
import uuid
from fastapi import Response, HTTPException
from fastapi.datastructures import QueryParams
from sqlmodel import select
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.instance import Instance
from openec2.api.describe_instance_attribute import DescribeInstanceAttributeResponse, InstanceInitiatedShutdownBehaviour, DisableApiTermination, DisableApiStop
def describe_instance_attribute(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
instance_id = params["InstanceId"]
attribute = params["Attribute"]
instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.owner_id == user.id, Instance.terminated == False)).first()
if instance is None:
raise HTTPException(
status_code=404,
)
return Response(
DescribeInstanceAttributeResponse(
requestId=uuid.uuid4().hex,
instanceId=instance.id,
instanceInitiatedShutdownBehaviour=InstanceInitiatedShutdownBehaviour(
value="stop",
) if attribute == "instanceInitiatedShutdownBehaviour" else None,
disableApiTermination=DisableApiTermination(
value="false",
) if attribute == "disableApiTermination" else None,
disableApiStop=DisableApiStop(
value="false",
) if attribute == "disableApiStop" else None,
).to_xml(),
media_type="application/xml",
)

View File

@@ -0,0 +1,35 @@
import uuid
from fastapi import Response
from fastapi.datastructures import QueryParams
from sqlmodel import select
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.instance import Instance
from openec2.api.describe_instance_types import DescribeInstanceTypesResponse, InstanceTypeInfo
def describe_instance_types(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
response: list[InstanceTypeInfo] = []
for name, instanceConfig in config.instances.types.items():
response.append(
InstanceTypeInfo(
instanceType=name,
),
)
return Response(
DescribeInstanceTypesResponse(
requestId=uuid.uuid4().hex,
instanceTypeSet=response,
nextToken=None,
).to_xml(),
media_type="application/xml",
)

View File

@@ -1,37 +1,87 @@
import uuid import uuid
from typing import cast
import datetime
from fastapi import Response from fastapi import Response
from fastapi.datastructures import QueryParams from fastapi.datastructures import QueryParams
from sqlmodel import select from sqlmodel import select, or_
from openec2.libvirt import LibvirtSingleton from openec2.libvirt import LibvirtSingleton
from openec2.api.describe_instances import InstanceDescription, DescribeInstancesResponse, DescribeInstancesResponseReservationSet, describe_instance from openec2.api.describe_instances import (
from openec2.api.shared import InstanceState InstanceDescription,
DescribeInstancesResponse,
ReservationSet,
InstanceState,
describe_instance,
)
from openec2.api.shared import Tag
from openec2.db.user import User
from openec2.config import OpenEC2Config from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep from openec2.db import DatabaseDep
from openec2.db.instance import Instance from openec2.db.instance import Instance
from openec2.utils.array import parse_array_plain
def describe_instances( def describe_instances(
params: QueryParams, params: QueryParams,
config: OpenEC2Config, config: OpenEC2Config,
db: DatabaseDep, db: DatabaseDep,
user: User,
): ):
if "InstanceId.1" in params:
instance_ids = parse_array_plain("InstanceId", cast(dict, params))
instance_expr = [Instance.id == instance_id for instance_id in instance_ids]
instances = db.exec(
select(Instance).where(Instance.owner_id == user.id, or_(*instance_expr)),
).all()
else:
instances = db.exec(
select(Instance).where(Instance.owner_id == user.id),
).all()
response_items: list[InstanceDescription] = [] response_items: list[InstanceDescription] = []
conn = LibvirtSingleton.of().connection conn = LibvirtSingleton.of().connection
for instance in db.exec(select(Instance)).all(): now = datetime.datetime.now()
dom = conn.lookupByName(instance.id) for instance in instances:
running = dom.isActive() # Include terminated instances for an hour
if instance.terminated:
assert instance.terminationDate is not None
if now <= instance.terminationDate + datetime.timedelta(hours=1):
response_items.append(
InstanceDescription(
instanceId=instance.id,
imageId=instance.imageId,
instanceState=InstanceState(
code=48,
name="terminated",
),
privateIpAddress=instance.privateIPv4,
tagSet=[
Tag(
key=key,
value=value,
)
for key, value in instance.tags.items()
],
),
)
continue
dom = conn.lookupByName(instance.id)
response_items.append( response_items.append(
describe_instance(instance, dom), describe_instance(instance, dom),
) )
return Response( return Response(
DescribeInstancesResponse( DescribeInstancesResponse(
request_id=uuid.uuid4().hex, requestId=uuid.uuid4().hex,
reservation_set=DescribeInstancesResponseReservationSet( reservationSet=[
item=response_items, ReservationSet(
), reservationId=instance.instanceId,
instancesSet=[instance],
)
for instance in response_items
],
).to_xml(), ).to_xml(),
media_type="application/xml", media_type="application/xml",
) )

View File

@@ -0,0 +1,99 @@
from typing import Literal, cast
from dataclasses import dataclass
import uuid
from fastapi import Response
from fastapi.datastructures import QueryParams
from sqlmodel import select
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.instance import Instance
from openec2.api.describe_tags import DescribeTagsResponse, TagDescription
from openec2.utils.array import parse_array_objects, parse_array_plain, find
@dataclass
class Filter:
name: Literal["resource-id"] | Literal["resource-type"] | Literal["key"] | str
values: list[str]
def match(self, instance: Instance) -> bool:
if self.name not in instance.tags:
return False
value: str | None
if self.name == "resource-type":
value = "instance"
elif self.name == "resource-id":
value = instance.id
elif self.name == "key":
return any(key in instance.tags for key in self.values)
elif self.name.startswith("tag:"):
value = instance.tags.get(self.name.replace("tag:", ""))
else:
raise Exception(f"Unknown filter name {self.name}")
return value in self.values
def value(self, instance: Instance) -> TagDescription:
if self.name == "resource-type":
return TagDescription(resourceType="instance")
elif self.name == "resource-id":
return TagDescription(resourceId=instance.id)
elif self.name == "key":
key_name = find(
self.values,
lambda x: x in instance.tags,
)
assert key_name is not None
key_value = instance.tags[key_name]
return TagDescription(
key=key_name,
value=key_value,
)
elif self.name.startswith("tag:"):
tag = self.name.replace("tag:", "")
return TagDescription(
key=tag,
value=instance.tags[tag],
)
else:
raise Exception(f"Unknown filter name {self.name}")
def describe_tags(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
filters: list[Filter] = []
for filter in parse_array_objects("Filter", cast(dict, params)):
filters.append(
Filter(
name=filter["Name"],
values=parse_array_plain("Values", filter),
)
)
response: list[TagDescription] = []
for instance in db.exec(select(Instance).where(Instance.owner_id == user.id)).all():
filter = find(
filters,
lambda f: f.match(instance),
)
if filter is None:
continue
response.append(filter.value(instance))
return Response(
DescribeTagsResponse(
requestId=uuid.uuid4().hex,
nextToken=None,
tagSet=response,
).to_xml(),
media_type="application/xml",
)

View File

@@ -0,0 +1,35 @@
import uuid
from sqlmodel import select
from fastapi import Response
from openec2.db import DatabaseDep
from openec2.config import OpenEC2Config
from fastapi.datastructures import QueryParams
from openec2.db.user import User
from openec2.db.instance import EBSVolume
from openec2.api.describe_volumes import DescribeVolumesResponse, VolumeSet, Volume
def describe_volumes(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
volumes = db.exec(select(EBSVolume).where(EBSVolume.owner_id == user.id)).all()
return Response(
DescribeVolumesResponse(
requestId=uuid.uuid4().hex,
volumeSet=VolumeSet(
item=[
Volume(
volumeId=volume.id,
multiAttachEnabled=volume.multi_attach_enabled,
)
for volume in volumes
],
),
).to_xml(),
media_type="application/xml",
)

View File

@@ -0,0 +1,61 @@
import uuid
import libvirt
from sqlmodel import select
from fastapi import Response
from fastapi.datastructures import QueryParams
from openec2.libvirt import LibvirtSingleton
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.instance import Instance, EBSVolume
from openec2.api.detach_volume import DetachVolumeResponse
from openec2.utils.libvirt import ebs_volume_to_libvirt_xml
def detach_volume(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
instance_id = params["InstanceId"]
volume_id = params["VolumeId"]
# Find the instance
instance = db.exec(
select(Instance).where(Instance.id == instance_id, Instance.owner_id == user.id)
).first()
if instance is None:
return
# Find the volume
volume = db.exec(
select(EBSVolume).where(
EBSVolume.id == volume_id, EBSVolume.owner_id == user.id
)
).first()
if volume is None:
return
if instance_id not in [i.id for i in volume.instances]:
return
# Remove the volume from the instance
domain = LibvirtSingleton.of().connection.lookupByName(instance_id)
domain.detachDeviceFlags(
ebs_volume_to_libvirt_xml(volume, config),
libvirt.VIR_DOMAIN_DEVICE_MODIFY_LIVE
if domain.isActive()
else libvirt.VIR_DOMAIN_DEVICE_MODIFY_CONFIG,
)
return Response(
DetachVolumeResponse(
requestId=uuid.uuid4().hex,
volumeId=volume_id,
instanceId=instance_id,
status="detached",
).to_xml(),
media_type="application/xml",
)

View File

@@ -0,0 +1,30 @@
import uuid
from fastapi import Response
from fastapi.datastructures import QueryParams
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.api.get_caller_identity import GetCallerIdentityResponse, GetCallerIdentityResult, ResponseMetadata
def get_caller_identity(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
return Response(
GetCallerIdentityResponse(
result=GetCallerIdentityResult(
arn=f"arn:aws:iam::{user.id}:user/{user.name}",
user_id=str(user.id),
account=str(user.id),
),
metadata=ResponseMetadata(
request_id=uuid.uuid4().hex,
),
).to_xml(),
media_type="application/xml",
)

View File

@@ -4,22 +4,28 @@ from urllib.parse import urlparse
import uuid import uuid
import shutil import shutil
from fastapi import HTTPException
from fastapi.datastructures import QueryParams from fastapi.datastructures import QueryParams
import requests import requests
from openec2.config import OpenEC2Config from openec2.config import OpenEC2Config, ConfigSingleton
from openec2.db import DatabaseDep from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.image import AMI from openec2.db.image import AMI
from openec2.utils.array import parse_tag_specification
def import_image( def import_image(
params: QueryParams, params: QueryParams,
config: OpenEC2Config, config: OpenEC2Config,
db: DatabaseDep, db: DatabaseDep,
user: User,
): ):
first_disk_image_url = params["DiskContainer.1.Url"] first_disk_image_url = params["DiskContainer.1.Url"]
url = urlparse(first_disk_image_url) url = urlparse(first_disk_image_url)
ami_id = uuid.uuid4().hex ami_id = uuid.uuid4().hex
tags: dict[str, str] = parse_tag_specification(cast(dict, params))
imageLocation = cast(Path, config.images) imageLocation = cast(Path, config.images)
imageLocation.mkdir(exist_ok=True) imageLocation.mkdir(exist_ok=True)
dst = imageLocation / ami_id dst = imageLocation / ami_id
@@ -32,6 +38,11 @@ def import_image(
for chunk in r.iter_content(8196): for chunk in r.iter_content(8196):
f.write(chunk) f.write(chunk)
case "file": case "file":
if not ConfigSingleton.of().config.debug:
raise HTTPException(
status_code=400,
detail="Unsupported scheme",
)
shutil.copy( shutil.copy(
url.path, url.path,
str(dst), str(dst),
@@ -44,7 +55,9 @@ def import_image(
id=ami_id, id=ami_id,
description=None, description=None,
originalFilename=filename, originalFilename=filename,
) owner_id=user.id,
tags=tags,
),
) )
db.commit() db.commit()
return ami_id return ami_id

View File

@@ -2,7 +2,7 @@ import base64
from typing import cast from typing import cast
import uuid import uuid
from fastapi import HTTPException from fastapi import HTTPException, Response
from fastapi.datastructures import QueryParams from fastapi.datastructures import QueryParams
from sqlmodel import select from sqlmodel import select
@@ -12,12 +12,18 @@ from openec2.utils.qemu import create_cow_copy
from openec2.db import DatabaseDep from openec2.db import DatabaseDep
from openec2.db.instance import Instance from openec2.db.instance import Instance
from openec2.db.image import AMI from openec2.db.image import AMI
from openec2.db.user import User
from openec2.api.run_instances import RunInstanceResponse, RunInstanceInstanceSet from openec2.api.run_instances import RunInstanceResponse, RunInstanceInstanceSet
from openec2.api.describe_instances import describe_instance from openec2.api.describe_instances import describe_instance
from openec2.utils.array import parse_array_objects from openec2.utils.array import parse_array_objects
from openec2.ipam import get_available_ipv4, is_ipv4_available, add_instance_dhcp_mapping from openec2.ipam import (
get_available_ipv4,
is_ipv4_available,
add_instance_dhcp_mapping,
)
from openec2.utils.ip import generate_available_mac from openec2.utils.ip import generate_available_mac
def create_libvirt_domain( def create_libvirt_domain(
name: str, name: str,
memory: int, memory: int,
@@ -84,17 +90,19 @@ def create_libvirt_domain(
</domain> </domain>
""" """
def run_instances( def run_instances(
params: QueryParams, params: QueryParams,
config: OpenEC2Config, config: OpenEC2Config,
db: DatabaseDep, db: DatabaseDep,
user: User,
): ):
image_id = params["ImageId"] image_id = params["ImageId"]
instance_type = params["InstanceType"] instance_type_name = params["InstanceType"]
instance_type = config.instances.types.get(params["InstanceType"]) instance_type = config.instances.types.get(instance_type_name)
if instance_type is None: if instance_type is None:
raise Exception(f"Unknown instance type {params["InstanceType"]}") raise Exception(f"Unknown instance type {params['InstanceType']}")
ami = db.exec(select(AMI).where(AMI.id == image_id)).first() ami = db.exec(select(AMI).where(AMI.id == image_id)).first()
if ami is None: if ami is None:
@@ -107,11 +115,11 @@ def run_instances(
) )
# Parse tags # Parse tags
# TODO: broken
tags: dict[str, str] = {} tags: dict[str, str] = {}
for spec in parse_array_objects("TagSpecification", cast(dict, params)): for spec in parse_array_objects("TagSpecification", cast(dict, params)):
for raw_tag in parse_array_objects("Tag", spec): for raw_tag in parse_array_objects("Tag", spec):
tags[raw_tag["Key"]] = raw_tag["Value"] tags[raw_tag["Key"]] = raw_tag["Value"]
print(f"Creating with tags {tags}")
# Get a private IPv4 # Get a private IPv4
instance_id = uuid.uuid4().hex instance_id = uuid.uuid4().hex
@@ -140,9 +148,15 @@ def run_instances(
id=instance_id, id=instance_id,
imageId=image_id, imageId=image_id,
tags=tags, tags=tags,
userData=base64.b64decode(value).decode() if (value := params.get("UserData")) is not None else None, userData=base64.b64decode(value).decode()
if (value := params.get("UserData")) is not None
else None,
privateIPv4=private_ipv4, privateIPv4=private_ipv4,
interfaceMac=mac, interfaceMac=mac,
instanceType=instance_type_name,
owner_id=user.id,
terminated=False,
terminationDate=None,
) )
db.add(instance) db.add(instance)
print("Inserted new instance") print("Inserted new instance")
@@ -161,9 +175,12 @@ def run_instances(
description = describe_instance(instance, domain) description = describe_instance(instance, domain)
db.commit() db.commit()
return RunInstanceResponse( return Response(
RunInstanceResponse(
request_id=uuid.uuid4().hex, request_id=uuid.uuid4().hex,
instance_set=RunInstanceInstanceSet( instancesSet=RunInstanceInstanceSet(
item=[description], item=[description],
), ),
).to_xml() ).to_xml(),
media_type="application/xml",
)

View File

@@ -1,6 +1,6 @@
import uuid import uuid
from fastapi import HTTPException from fastapi import HTTPException, Response
from fastapi.datastructures import QueryParams from fastapi.datastructures import QueryParams
from sqlmodel import select from sqlmodel import select
@@ -8,48 +8,51 @@ from openec2.libvirt import LibvirtSingleton
from openec2.config import OpenEC2Config from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep from openec2.db import DatabaseDep
from openec2.db.instance import Instance from openec2.db.instance import Instance
from openec2.db.user import User
from openec2.utils.array import parse_array_plain from openec2.utils.array import parse_array_plain
from openec2.api.start_instances import InstanceState, StartInstancesResponseInstancesSetInstance, StartInstancesResponse, StartInstancesResponseInstancesSet from openec2.api.shared import InstanceInfo, InstancesSet
from openec2.api.describe_instances import describe_instance_state
from openec2.api.start_instances import StartInstancesResponse
def start_instances( def start_instances(
params: QueryParams, params: QueryParams,
config: OpenEC2Config, config: OpenEC2Config,
db: DatabaseDep, db: DatabaseDep,
user: User,
): ):
conn = LibvirtSingleton.of().connection conn = LibvirtSingleton.of().connection
instances: list[StartInstancesResponseInstancesSetInstance] = [] instances: list[InstanceInfo] = []
for instance_id in parse_array_plain("InstanceId", params): for instance_id in parse_array_plain("InstanceId", params):
instance = db.exec(select(Instance).where(Instance.id == instance_id)).first() instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.terminated == False)).first()
if instance is None: if instance is None:
raise HTTPException(status_code=404, detail="Unknown instance") raise HTTPException(status_code=404, detail="Unknown instance")
dom = conn.lookupByName(instance_id) # Check for permission issues
if instance.owner_id != user.id:
# TODO: Add the error to the response
continue
running = dom.isActive() dom = conn.lookupByName(instance_id)
prev_state = InstanceState( prev_state = describe_instance_state(dom)
code=16 if running else 80,
name="running" if running else "stopped",
)
if not dom.isActive(): if not dom.isActive():
dom.create() dom.create()
running = dom.isActive() current_state = describe_instance_state(dom)
current_state = InstanceState(
code=16 if running else 80,
name="running" if running else "stopped",
)
instances.append( instances.append(
StartInstancesResponseInstancesSetInstance( InstanceInfo(
instance_id=instance_id, instanceId=instance_id,
previous_state=prev_state, previousState=prev_state,
current_state=current_state, currentState=current_state,
), ),
) )
return StartInstancesResponse( return Response(
StartInstancesResponse(
request_id=uuid.uuid4().hex, request_id=uuid.uuid4().hex,
instances_set=StartInstancesResponseInstancesSet( instancesSet=InstancesSet(
item=instances, item=instances,
), ),
).to_xml() ).to_xml(),
media_type="application/xml",
)

View File

@@ -1,6 +1,6 @@
import uuid import uuid
from fastapi import HTTPException from fastapi import HTTPException, Response
from fastapi.datastructures import QueryParams from fastapi.datastructures import QueryParams
from sqlmodel import select from sqlmodel import select
@@ -8,48 +8,53 @@ from openec2.libvirt import LibvirtSingleton
from openec2.config import OpenEC2Config from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep from openec2.db import DatabaseDep
from openec2.db.instance import Instance from openec2.db.instance import Instance
from openec2.db.user import User
from openec2.utils.array import parse_array_plain from openec2.utils.array import parse_array_plain
from openec2.api.shared import InstanceState from openec2.api.shared import InstanceInfo, InstancesSet
from openec2.api.stop_instances import StopInstancesResponse, StopInstancesResponseInstancesSet, StopInstancesResponseInstancesSetInstance from openec2.api.describe_instances import describe_instance_state
from openec2.api.stop_instances import StopInstancesResponse
def stop_instances( def stop_instances(
params: QueryParams, params: QueryParams,
config: OpenEC2Config, config: OpenEC2Config,
db: DatabaseDep, db: DatabaseDep,
user: User,
): ):
conn = LibvirtSingleton.of().connection conn = LibvirtSingleton.of().connection
instances: list[StopInstancesResponseInstancesSetInstance] = [] instances: list[InstanceInfo] = []
for instance_id in parse_array_plain("InstanceId", params): for instance_id in parse_array_plain("InstanceId", params):
instance = db.exec(select(Instance).where(Instance.id == instance_id)).first() instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.terminated == False)).first()
if instance is None: if instance is None:
raise HTTPException(status_code=404, detail="Unknown instance") raise HTTPException(status_code=404, detail="Unknown instance")
# Check for permission issues
if instance.owner_id != user.id:
# TODO: Add the error to the response
continue
dom = conn.lookupByName(instance_id) dom = conn.lookupByName(instance_id)
running = dom.isActive() running = dom.isActive()
prev_state = InstanceState( prev_state = describe_instance_state(dom)
code=16 if running else 80,
name="running" if running else "stopped",
)
if running: if running:
dom.shutdown() dom.shutdown()
running = dom.isActive() running = dom.isActive()
current_state = InstanceState( current_state = describe_instance_state(dom)
code=16 if running else 80,
name="running" if running else "stopped",
)
instances.append( instances.append(
StopInstancesResponseInstancesSetInstance( InstanceInfo(
instance_id=instance_id, instanceId=instance_id,
previous_state=prev_state, previousState=prev_state,
current_state=current_state, currentState=current_state,
), ),
) )
return StopInstancesResponse( return Response(
request_id=uuid.uuid4().hex, StopInstancesResponse(
instances_set=StopInstancesResponseInstancesSet( requestId=uuid.uuid4().hex,
instancesSet=InstancesSet(
item=instances, item=instances,
), ),
).to_xml() ).to_xml(),
media_type="application/xml",
)

View File

@@ -1,6 +1,9 @@
import logging import logging
from typing import cast
import uuid
import datetime
from fastapi import HTTPException from fastapi import Response
from fastapi.datastructures import QueryParams from fastapi.datastructures import QueryParams
from sqlmodel import select from sqlmodel import select
@@ -8,38 +11,65 @@ from openec2.libvirt import LibvirtSingleton
from openec2.config import OpenEC2Config from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep from openec2.db import DatabaseDep
from openec2.db.instance import Instance from openec2.db.instance import Instance
from openec2.db.user import User
from openec2.utils.array import parse_array_plain from openec2.utils.array import parse_array_plain
from openec2.images import garbage_collect_image from openec2.images import garbage_collect_image
from openec2.ipam import remove_instance_dhcp_mapping from openec2.ipam import remove_instance_dhcp_mapping
from openec2.api.shared import InstanceInfo
from openec2.api.describe_instances import describe_instance_state
from openec2.api.terminate_instances import TerminateInstancesResponse, InstancesSet
logger = logging.getLogger() logger = logging.getLogger()
def terminate_instances( def terminate_instances(
params: QueryParams, params: QueryParams,
config: OpenEC2Config, config: OpenEC2Config,
db: DatabaseDep, db: DatabaseDep,
user: User,
): ):
instances: list[InstanceInfo] = []
conn = LibvirtSingleton.of().connection conn = LibvirtSingleton.of().connection
image_ids: set[str] = set() image_ids: set[str] = set()
for instance_id in parse_array_plain("InstanceId", params): for instance_id in parse_array_plain("InstanceId", params):
instance = db.exec(select(Instance).where(Instance.id == instance_id)).first() instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.owner_id == user.id)).first()
if instance is None: if instance is None:
raise HTTPException(status_code=404, detail="Unknown instance") continue
# raise HTTPException(status_code=404, detail="Unknown instance")
dom = conn.lookupByName(instance_id) dom = conn.lookupByName(instance_id)
prev_state = describe_instance_state(dom)
if dom.isActive(): if dom.isActive():
dom.shutdown() dom.shutdown()
current_state = describe_instance_state(dom)
dom.undefine() dom.undefine()
instances.append(
InstanceInfo(
instanceId=instance.id,
currentState=current_state,
previousState=prev_state,
),
)
# Delete files # Delete files
logger.debug(f"Removing {config.instances.location / instance_id}") logger.debug(f"Removing {config.instances.location / instance_id}")
instance_disk = config.instances.location / instance_id instance_disk = config.instances.location / instance_id
instance_disk.unlink() instance_disk.unlink()
image_ids.add(instance.imageId) for volume in instance.ebs_volumes:
remove_instance_dhcp_mapping(instance.id, instance.interfaceMac, instance.privateIPv4, db) volume.instances.remove(instance)
db.delete(instance)
image_ids.add(cast(str, instance.imageId))
remove_instance_dhcp_mapping(
instance.id, instance.interfaceMac, instance.privateIPv4, db
)
# Mark the instance as terminated
instance.terminated = True
instance.terminationDate = datetime.datetime.now()
db.commit() db.commit()
@@ -47,4 +77,12 @@ def terminate_instances(
for image_id in image_ids: for image_id in image_ids:
garbage_collect_image(image_id, db) garbage_collect_image(image_id, db)
return "OK" return Response(
TerminateInstancesResponse(
requestId=uuid.uuid4().hex,
instancesSet=InstancesSet(
item=instances,
),
).to_xml(),
media_type="application/xml",
)

View File

@@ -0,0 +1,17 @@
from pydantic_xml import BaseXmlModel, element
class AttachVolumeResponse(
BaseXmlModel,
tag="AttachVolumeResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
):
requestId: str = element()
volumeId: str = element()
instanceId: str = element()
device: str = element()
status: str = element()

View File

@@ -0,0 +1,10 @@
from pydantic_xml import BaseXmlModel, element
class CreateTagsResponse(
BaseXmlModel,
nsmap={"": ""},
):
requestId: str = element()
return_: bool = element(tag="return")

View File

@@ -0,0 +1,15 @@
from pydantic_xml import BaseXmlModel, element
class CreateVolumeResponse(
BaseXmlModel,
tag="CreateVolumeResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
):
requestId: str = element()
volumeId: str = element()
availabilityZone: str = element()
multiAttachEnabled: bool = element()

View File

@@ -0,0 +1 @@
from pydantic_xml import BaseXmlModel, wrapped, element

View File

@@ -1,4 +1,7 @@
from pydantic_xml import BaseXmlModel, element from pydantic_xml import BaseXmlModel, wrapped, element
from openec2.api.shared import Tag
class Image(BaseXmlModel): class Image(BaseXmlModel):
imageId: str = element() imageId: str = element()
@@ -7,14 +10,14 @@ class Image(BaseXmlModel):
name: str = element() name: str = element()
class ImagesSet(BaseXmlModel, tag="imagesSet"): tagSet: list[Tag] = wrapped("tagSet", element(tag="item"))
items: list[Image] = element(tag="item")
class DescribeImagesResponse( class DescribeImagesResponse(
BaseXmlModel, BaseXmlModel,
tag="DescribeImagesResponse", tag="DescribeImagesResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"} nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
): ):
requestId: str = element() requestId: str = element()
imagesSet: ImagesSet = element() imagesSet: list[Image] = wrapped("imagesSet", element(tag="item"))

View File

@@ -0,0 +1,26 @@
from pydantic_xml import BaseXmlModel, element
class InstanceInitiatedShutdownBehaviour(BaseXmlModel):
value: str = element()
class DisableApiTermination(BaseXmlModel):
value: str = element()
class DisableApiStop(BaseXmlModel):
value: str = element()
class DescribeInstanceAttributeResponse(
BaseXmlModel,
tag="DescribeInstanceAttributeResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
):
requestId: str = element()
instanceId: str = element()
instanceInitiatedShutdownBehaviour: InstanceInitiatedShutdownBehaviour | None = element(defaut=None)
disableApiTermination: DisableApiTermination | None = element(default=None)
disableApiStop: DisableApiStop | None = element(default=None)

View File

@@ -0,0 +1,18 @@
from pydantic_xml import BaseXmlModel, element, wrapped
class InstanceTypeInfo(BaseXmlModel, tag="item"):
instanceType: str = element()
class DescribeInstanceTypesResponse(
BaseXmlModel,
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
):
requestId: str = element()
instanceTypeSet: list[InstanceTypeInfo] = wrapped(
"instanceTypeSet",
element(tag="item"),
)
nextToken: str | None = element()

View File

@@ -1,56 +1,55 @@
from pydantic_xml import BaseXmlModel, element from pydantic_xml import BaseXmlModel, wrapped, element
import libvirt import libvirt
from openec2.db.instance import Instance from openec2.db.instance import Instance
from openec2.api.shared import InstanceState from openec2.api.shared import InstanceState, Tag
class InstanceTag(BaseXmlModel):
key: str = element()
value: str = element()
class InstanceTagSet(BaseXmlModel):
item: list[InstanceTag] = element()
class InstanceDescription( class InstanceDescription(
BaseXmlModel, BaseXmlModel,
tag="item", tag="item",
): ):
instance_id: str = element(tag="instanceId") instanceId: str = element()
image_id: str = element(tag="imageId") imageId: str = element()
instance_state: InstanceState = element(tag="instanceState") instanceState: InstanceState = element()
tag_set: InstanceTagSet = element(tag="tagSet") privateIpAddress: str = element()
tagSet: list[Tag] = wrapped("tagSet", element(tag="item"))
class ReservationSet(BaseXmlModel):
reservationId: str = element()
instancesSet: list[InstanceDescription] = wrapped("instancesSet", element(tag="item"))
class DescribeInstancesResponseReservationSet(
BaseXmlModel,
tag="reservationSet",
):
item: list[InstanceDescription] = element()
class DescribeInstancesResponse( class DescribeInstancesResponse(
BaseXmlModel, BaseXmlModel,
tag="DescribeInstancesResponse", tag="DescribeInstancesResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"} nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
): ):
request_id: str = element(tag="requestId") requestId: str = element()
reservation_set: DescribeInstancesResponseReservationSet = element("reservationSet") reservationSet: list[ReservationSet] = wrapped("reservationSet", element("item"))
def describe_instance(instance: Instance, domain: libvirt.virDomain) -> InstanceDescription: def describe_instance_state(domain: libvirt.virDomain) -> InstanceState:
running = domain.isActive() running = domain.isActive()
return InstanceDescription( return InstanceState(
instance_id=instance.id,
image_id=instance.imageId,
instance_state=InstanceState(
code=16 if running else 80, code=16 if running else 80,
name="running" if running else "stopped", name="running" if running else "stopped",
), )
tag_set=InstanceTagSet(
item=[
InstanceTag( def describe_instance(
instance: Instance, domain: libvirt.virDomain
) -> InstanceDescription:
return InstanceDescription(
instanceId=instance.id,
imageId=instance.imageId,
instanceState=describe_instance_state(domain),
privateIpAddress=instance.privateIPv4,
tagSet=[
Tag(
key=key, key=key,
value=value, value=value,
) for key, value in instance.tags.items() )
], for key, value in instance.tags.items()
), ],
) )

View File

@@ -0,0 +1,22 @@
from pydantic_xml import BaseXmlModel, wrapped, element
class TagDescription(BaseXmlModel):
key: str | None = element(default=None)
resourceId: str | None = element(default=None)
resourceType: str | None = element(default=None)
value: str | None = element(default=None)
class DescribeTagsResponse(
BaseXmlModel,
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"}
):
requestId: str = element()
nextToken: str | None = element()
tagSet: list[TagDescription] = wrapped("tagSet", element(tag="item"))

View File

@@ -0,0 +1,20 @@
from pydantic_xml import BaseXmlModel, element
class Volume(BaseXmlModel):
volumeId: str = element()
multiAttachEnabled: bool = element()
class VolumeSet(BaseXmlModel):
item: list[Volume] = element(tag="item")
class DescribeVolumesResponse(
BaseXmlModel,
tag="DescribeVolumesResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
):
requestId: str = element()
volumeSet: VolumeSet = element()

View File

@@ -0,0 +1,15 @@
from pydantic_xml import BaseXmlModel, element
class DetachVolumeResponse(
BaseXmlModel,
tag="DetachVolumeResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
):
requestId: str = element()
volumeId: str = element()
instanceId: str = element()
status: str = element()

View File

@@ -0,0 +1,18 @@
from pydantic_xml import BaseXmlModel, element
class GetCallerIdentityResult(BaseXmlModel):
arn: str = element(tag="Arn")
user_id: str = element(tag="UserId")
account: str = element(tag="Account")
class ResponseMetadata(BaseXmlModel):
request_id: str = element(tag="RequestId")
class GetCallerIdentityResponse(
BaseXmlModel,
nsmap={"": "https://sts.amazonaws.com/doc/2011-06-15/"}
):
result: GetCallerIdentityResult = element(tag="GetCallerIdentityResult")
metadata: ResponseMetadata = element()

View File

@@ -1,13 +1,13 @@
from pydantic_xml import BaseXmlModel, element from pydantic_xml import BaseXmlModel, element
from openec2.db.instance import Instance
from openec2.api.shared import InstanceState
from openec2.api.describe_instances import InstanceDescription from openec2.api.describe_instances import InstanceDescription
class RunInstanceInstanceSet(BaseXmlModel): class RunInstanceInstanceSet(BaseXmlModel):
item: list[InstanceDescription] = element() item: list[InstanceDescription] = element()
class RunInstanceResponse(BaseXmlModel): class RunInstanceResponse(BaseXmlModel):
request_id: str = element(tag="requestId") request_id: str = element(tag="requestId")
instance_set: RunInstanceInstanceSet = element(tag="instanceSet") instancesSet: RunInstanceInstanceSet = element(tag="instancesSet")

View File

@@ -4,3 +4,18 @@ from pydantic_xml import BaseXmlModel, element
class InstanceState(BaseXmlModel): class InstanceState(BaseXmlModel):
code: int = element() code: int = element()
name: str = element() name: str = element()
class InstanceInfo(BaseXmlModel):
instanceId: str = element()
currentState: InstanceState = element()
previousState: InstanceState = element()
class InstancesSet(BaseXmlModel, tag="instancesSet"):
item: list[InstanceInfo] = element()
class Tag(BaseXmlModel):
key: str = element()
value: str = element()

View File

@@ -1,26 +1,12 @@
from pydantic_xml import BaseXmlModel, element from pydantic_xml import BaseXmlModel, element
from openec2.api.shared import InstanceState from openec2.api.shared import InstancesSet
class StartInstancesResponseInstancesSetInstance(
BaseXmlModel,
tag="item",
):
instance_id: str = element(tag="instanceId")
previous_state: InstanceState = element("previousState")
current_state: InstanceState = element("currentState")
class StartInstancesResponseInstancesSet(
BaseXmlModel,
tag="instancesSet",
):
item: list[StartInstancesResponseInstancesSetInstance] = element()
class StartInstancesResponse( class StartInstancesResponse(
BaseXmlModel, BaseXmlModel,
tag="StartInstancesResponse", tag="StartInstancesResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"} nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
): ):
request_id: str = element(tag="requestId") request_id: str = element(tag="requestId")
instances_set: StartInstancesResponseInstancesSet instancesSet: InstancesSet = element()

View File

@@ -1,26 +1,12 @@
from pydantic_xml import BaseXmlModel, element from pydantic_xml import BaseXmlModel, element
from openec2.api.shared import InstanceState from openec2.api.shared import InstancesSet
class StopInstancesResponseInstancesSetInstance(
BaseXmlModel,
tag="item",
):
instance_id: str = element(tag="instanceId")
previous_state: InstanceState = element("previousState")
current_state: InstanceState = element("currentState")
class StopInstancesResponseInstancesSet(
BaseXmlModel,
tag="instancesSet",
):
item: list[StopInstancesResponseInstancesSetInstance] = element()
class StopInstancesResponse( class StopInstancesResponse(
BaseXmlModel, BaseXmlModel,
tag="StopInstancesResponse", tag="StopInstancesResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"} nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
): ):
request_id: str = element(tag="requestId") requestId: str = element(tag="requestId")
instances_set: StopInstancesResponseInstancesSet instancesSet: InstancesSet = element()

View File

@@ -0,0 +1,13 @@
from pydantic_xml import BaseXmlModel, element
from openec2.api.shared import InstancesSet
class TerminateInstancesResponse(
BaseXmlModel,
tag="TerminateInstancesResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
):
requestId: str = element()
instancesSet: InstancesSet = element()

View File

@@ -0,0 +1,46 @@
import argparse
import secrets
import base64
from openec2.db import get_session
from openec2.db.user import User
from sqlmodel import select
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--name", type=str, required=True)
args = parser.parse_args()
secret_access_key = base64.b64encode(
secrets.token_bytes(64),
).decode()
db = next(get_session())
access_key = base64.b64encode(
secrets.token_bytes(32),
).decode()
while True:
if db.exec(select(User).where(User.access_key == access_key)).first() is None:
break
access_key = base64.b64encode(
secrets.token_bytes(32),
).decode()
db.add(
User(
name=args.name,
access_key=access_key,
secret_access_key=secret_access_key,
)
)
db.commit()
print(f"Access key: {access_key}")
print(f"Secret access key: {secret_access_key}")
if __name__ == "__main__":
main()

View File

@@ -3,19 +3,23 @@ from fastapi import Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Annotated from typing import Annotated
class _OpenEC2InstanceType(BaseModel): class _OpenEC2InstanceType(BaseModel):
memory: int # MiB memory: int # MiB
vcpu: float vcpu: float
disk: int # Gi disk: int # Gi
class _OpenEC2InstanceConfig(BaseModel): class _OpenEC2InstanceConfig(BaseModel):
location: Path location: Path
volumes: Path
types: dict[str, _OpenEC2InstanceType] types: dict[str, _OpenEC2InstanceType]
class _OpenEC2LibvirtConfig(BaseModel): class _OpenEC2LibvirtConfig(BaseModel):
connection: str connection: str
class _OpenEC2DatabaseConfig(BaseModel): class _OpenEC2DatabaseConfig(BaseModel):
# DB URL for sqlmodel # DB URL for sqlmodel
url: str url: str
@@ -23,6 +27,7 @@ class _OpenEC2DatabaseConfig(BaseModel):
# Print SQL statements # Print SQL statements
debug: bool debug: bool
class _OpenEC2Config(BaseModel): class _OpenEC2Config(BaseModel):
images: Path images: Path
seed: Path seed: Path
@@ -30,6 +35,8 @@ class _OpenEC2Config(BaseModel):
libvirt: _OpenEC2LibvirtConfig libvirt: _OpenEC2LibvirtConfig
database: _OpenEC2DatabaseConfig database: _OpenEC2DatabaseConfig
debug: bool debug: bool
insecure: bool
def _get_config() -> _OpenEC2Config: def _get_config() -> _OpenEC2Config:
# TODO: Read from disk # TODO: Read from disk
@@ -38,6 +45,7 @@ def _get_config() -> _OpenEC2Config:
seed=Path("/home/alexander/openec2/seed"), seed=Path("/home/alexander/openec2/seed"),
instances=_OpenEC2InstanceConfig( instances=_OpenEC2InstanceConfig(
location=Path("/home/alexander/openec2/instances"), location=Path("/home/alexander/openec2/instances"),
volumes=Path("/home/alexander/openec2/volumes"),
types={ types={
"micro": _OpenEC2InstanceType( "micro": _OpenEC2InstanceType(
memory=1024, memory=1024,
@@ -46,16 +54,16 @@ def _get_config() -> _OpenEC2Config:
), ),
}, },
), ),
libvirt=_OpenEC2LibvirtConfig( libvirt=_OpenEC2LibvirtConfig(connection="qemu:///system"),
connection="qemu:///system"
),
debug=True, debug=True,
insecure=False,
database=_OpenEC2DatabaseConfig( database=_OpenEC2DatabaseConfig(
url="sqlite:////home/alexander/openec2/db2.sqlite3", url="sqlite:////home/alexander/openec2/db2.sqlite3",
debug=True, debug=False,
), ),
) )
class ConfigSingleton: class ConfigSingleton:
__instance: "ConfigSingleton | None" = None __instance: "ConfigSingleton | None" = None
@@ -73,4 +81,5 @@ class ConfigSingleton:
ConfigSingleton.__instance = ConfigSingleton() ConfigSingleton.__instance = ConfigSingleton()
return ConfigSingleton.__instance return ConfigSingleton.__instance
OpenEC2Config = Annotated[_OpenEC2Config, Depends(ConfigSingleton.of().get_config)] OpenEC2Config = Annotated[_OpenEC2Config, Depends(ConfigSingleton.of().get_config)]

View File

@@ -12,8 +12,10 @@ engine = create_engine(
echo=ConfigSingleton.of().config.database.debug, echo=ConfigSingleton.of().config.database.debug,
) )
def get_session() -> Generator[Session]: def get_session() -> Generator[Session]:
with Session(engine) as session: with Session(engine) as session:
yield session yield session
DatabaseDep = Annotated[Session, Depends(get_session)] DatabaseDep = Annotated[Session, Depends(get_session)]

View File

@@ -1,4 +1,5 @@
from sqlmodel import SQLModel, Field from sqlmodel import SQLModel, Field, Column, JSON
class AMI(SQLModel, table=True): class AMI(SQLModel, table=True):
# ID of the AMI # ID of the AMI
@@ -12,3 +13,9 @@ class AMI(SQLModel, table=True):
# Was the image registered # Was the image registered
deregistered: bool = Field(default=False) deregistered: bool = Field(default=False)
# Owner of the image who created it
owner_id: int = Field(foreign_key="user.id")
# Tags associated with the AMI
tags: dict = Field(sa_column=Column(JSON), default={})

View File

@@ -1,4 +1,37 @@
from sqlmodel import SQLModel, Field, JSON, Column from pathlib import Path
from datetime import datetime
from sqlmodel import SQLModel, Field, JSON, Column, Relationship
from openec2.config import OpenEC2Config
class EBSVolumeInstanceLink(SQLModel, table=True):
instance_id: str | None = Field(
default=None, foreign_key="instance.id", primary_key=True
)
ebs_volume_id: str | None = Field(
default=None, foreign_key="ebsvolume.id", primary_key=True
)
class EBSVolume(SQLModel, table=True):
id: str = Field(primary_key=True)
availability_zone: str
multi_attach_enabled: bool
instances: list["Instance"] = Relationship(
back_populates="ebs_volumes", link_model=EBSVolumeInstanceLink
)
owner_id: int = Field(foreign_key="user.id")
def path(self, config: OpenEC2Config) -> Path:
"""Compute the path of the volume on disk."""
return config.instances.volumes / self.id
class Instance(SQLModel, table=True): class Instance(SQLModel, table=True):
id: str = Field(default=None, primary_key=True) id: str = Field(default=None, primary_key=True)
@@ -6,7 +39,9 @@ class Instance(SQLModel, table=True):
# Tags associated with the VM # Tags associated with the VM
tags: dict = Field(sa_column=Column(JSON), default={}) tags: dict = Field(sa_column=Column(JSON), default={})
# ImageID of the used AMI instanceType: str
# ImageID of the used AMI. None only if terminated == True.
imageId: str imageId: str
# Optional user data associated with the VM # Optional user data associated with the VM
@@ -17,3 +52,17 @@ class Instance(SQLModel, table=True):
# Private IPv4 of the instance # Private IPv4 of the instance
privateIPv4: str privateIPv4: str
# The owner that creatd the resource.
owner_id: int = Field(foreign_key="user.id")
# Attached EBS volumes
ebs_volumes: list[EBSVolume] = Relationship(
back_populates="instances", link_model=EBSVolumeInstanceLink
)
# Is the instance terminated
terminated: bool
# Date at which the instance got terminated
terminationDate: datetime | None

View File

@@ -19,6 +19,4 @@ class IPAMEntry(SQLModel, table=True):
def set_ipv4(self, addr: str): def set_ipv4(self, addr: str):
self.ipv4_addr_raw = ipv4_to_int(addr) self.ipv4_addr_raw = ipv4_to_int(addr)
__table_args = ( __table_args = (PrimaryKeyConstraint("ipv4_addr_raw", "vpc_id"),)
PrimaryKeyConstraint("ipv4_addr_raw", "vpc_id"),
)

12
src/openec2/db/user.py Normal file
View File

@@ -0,0 +1,12 @@
from sqlmodel import SQLModel, Field
class User(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
# Human-readable id
name: str
# For request signing
access_key: str
secret_access_key: str

View File

@@ -1,11 +1,34 @@
from sqlmodel import SQLModel, Field, PrimaryKeyConstraint from ipaddress import ip_network
from sqlmodel import SQLModel, Field
class VPC(SQLModel, table=True): class VPC(SQLModel, table=True):
# ID of the VPC # ID of the VPC
id: str = Field(default=None, primary_key=True) id: str = Field(default=None, primary_key=True)
# Subnet mask # Number-suffix of the bridge device
subnet: str bridge_num: int
# Base IPv4 # Base IPv4
ipv4_base: str cidr: str
# Flag indicating whether this VPC is the user's default VPC
default: bool
# Owning user
owner_id: int = Field(foreign_key="user.id")
@property
def virbr(self) -> str:
return f"virbr{self.bridge_num}"
@property
def gateway(self) -> str:
network = ip_network(self.cidr)
return f"{network[1]}/{network.prefixlen}"
@property
def broadcast(self) -> str:
network = ip_network(self.cidr)
return f"{network[255]}"

View File

@@ -7,13 +7,15 @@ from openec2.db.image import AMI
def garbage_collect_image(image_id: str, db: DatabaseDep): def garbage_collect_image(image_id: str, db: DatabaseDep):
instances = db.exec(select(Instance).where(Instance.imageId == image_id)).all() instances = db.exec(select(Instance).where(Instance.imageId == image_id, Instance.terminated == False)).all()
if instances: if instances:
print("Instances sill using AMI. Not cleaning up") print("Instances sill using AMI. Not cleaning up")
print(instances) print(instances)
return return
ami = db.exec(select(AMI).where(AMI.id == image_id, AMI.deregistered == True)).first() ami = db.exec(
select(AMI).where(AMI.id == image_id, AMI.deregistered == True)
).first()
if ami is not None: if ami is not None:
db.delete(ami) db.delete(ami)
db.commit() db.commit()

View File

@@ -34,9 +34,16 @@ def add_instance_dhcp_mapping(instance_id: str, mac: str, ipv4: str, db: Databas
flags=libvirt.VIR_NETWORK_UPDATE_AFFECT_LIVE, flags=libvirt.VIR_NETWORK_UPDATE_AFFECT_LIVE,
) )
def remove_instance_dhcp_mapping(instance_id: str, mac: str ,ipv4: str, db: DatabaseDep):
def remove_instance_dhcp_mapping(
instance_id: str, mac: str, ipv4: str, db: DatabaseDep
):
i = ipv4_to_int(ipv4) i = ipv4_to_int(ipv4)
entry = db.exec(select(IPAMEntry).where(IPAMEntry.ipv4_addr_raw == i, IPAMEntry.instance_id == instance_id)).first() entry = db.exec(
select(IPAMEntry).where(
IPAMEntry.ipv4_addr_raw == i, IPAMEntry.instance_id == instance_id
)
).first()
db.delete(entry) db.delete(entry)
# Tell libvirt about this mapping # Tell libvirt about this mapping
@@ -49,13 +56,21 @@ def remove_instance_dhcp_mapping(instance_id: str, mac: str ,ipv4: str, db: Data
flags=libvirt.VIR_NETWORK_UPDATE_AFFECT_LIVE, flags=libvirt.VIR_NETWORK_UPDATE_AFFECT_LIVE,
) )
def is_ipv4_available(ipv4: str, db: DatabaseDep) -> bool: def is_ipv4_available(ipv4: str, db: DatabaseDep) -> bool:
i = ipv4_to_int(ipv4) i = ipv4_to_int(ipv4)
return db.exec(select(IPAMEntry).where(IPAMEntry.ipv4_addr_raw == i)).first() is None return (
db.exec(select(IPAMEntry).where(IPAMEntry.ipv4_addr_raw == i)).first() is None
)
def get_available_ipv4(db: DatabaseDep) -> str: def get_available_ipv4(db: DatabaseDep) -> str:
entries = db.exec(select(IPAMEntry)).all() entries = db.exec(select(IPAMEntry)).all()
# TODO: Use the VPC's subnet # TODO: Use the VPC's subnet
max_ip = max(e.ipv4_addr_raw for e in entries) if entries else ipv4_to_int("192.168.122.2") max_ip = (
max(e.ipv4_addr_raw for e in entries)
if entries
else ipv4_to_int("192.168.122.2")
)
# TODO: Check if we're still inside the subnet # TODO: Check if we're still inside the subnet
return int_to_ipv4(max_ip + 1) return int_to_ipv4(max_ip + 1)

View File

@@ -1,10 +1,15 @@
from urllib.parse import parse_qs
from typing import cast
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from sqlalchemy import select from sqlalchemy import select
from sqlmodel import SQLModel from sqlmodel import SQLModel
from openec2.security.aws import AWSSignature
from openec2.utils.text import multiline_yaml_response from openec2.utils.text import multiline_yaml_response
from openec2.config import OpenEC2Config from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep, engine from openec2.db import DatabaseDep, engine
from openec2.db.user import User
from openec2.actions.describe_images import describe_images from openec2.actions.describe_images import describe_images
from openec2.actions.import_image import import_image from openec2.actions.import_image import import_image
from openec2.actions.describe_instances import describe_instances from openec2.actions.describe_instances import describe_instances
@@ -13,24 +18,77 @@ from openec2.actions.terminate_instances import terminate_instances
from openec2.actions.start_instances import start_instances from openec2.actions.start_instances import start_instances
from openec2.actions.stop_instances import stop_instances from openec2.actions.stop_instances import stop_instances
from openec2.actions.deregister_image import deregister_image from openec2.actions.deregister_image import deregister_image
from openec2.actions.create_volume import create_volume
from openec2.actions.attach_volume import attach_volume
from openec2.actions.describe_volumes import describe_volumes
from openec2.actions.detach_volume import detach_volume
from openec2.db.instance import Instance from openec2.db.instance import Instance
from openec2.actions.get_caller_identity import get_caller_identity
from openec2.actions.describe_instance_types import describe_instance_types
from openec2.actions.describe_tags import describe_tags
from openec2.actions.create_tags import create_tags
from openec2.actions.describe_instance_attribute import describe_instance_attribute
app = FastAPI() app = FastAPI()
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
@app.get("/healthz", response_model=None) @app.get("/healthz", response_model=None)
def healthz(): def healthz():
return { return {
"status": "OK", "status": "OK",
} }
@app.get("/Action", response_model=None) @app.get("/Action", response_model=None)
def action(request: Request, config: OpenEC2Config, db: DatabaseDep): def get_action(
action = request.query_params["Action"] request: Request, config: OpenEC2Config, db: DatabaseDep, user: AWSSignature
return { ):
print("GET Action")
return run_action(
request,
config,
db,
cast(dict, request.query_params),
user,
)
@app.post("/Action", response_model=None)
async def post_action(
request: Request, config: OpenEC2Config, db: DatabaseDep, user: AWSSignature
):
print("POST Action")
body = (await request.body()).decode()
print(f"--> {body}")
query_params = {
key: value[0]
for key, value in parse_qs(body).items()
}
return run_action(
request,
config,
db,
cast(dict, query_params),
user,
)
def run_action(
request: Request,
config: OpenEC2Config,
db: DatabaseDep,
query_params: dict[str, str],
user: User,
):
action = query_params["Action"]
action_function = {
"ImportImage": import_image, "ImportImage": import_image,
"DescribeImages": describe_images, "DescribeImages": describe_images,
"RunInstances": run_instances, "RunInstances": run_instances,
@@ -39,7 +97,26 @@ def action(request: Request, config: OpenEC2Config, db: DatabaseDep):
"StartInstances": start_instances, "StartInstances": start_instances,
"StopInstances": stop_instances, "StopInstances": stop_instances,
"DeregisterImage": deregister_image, "DeregisterImage": deregister_image,
}[action](request.query_params, config, db) "CreateVolume": create_volume,
"AttachVolume": attach_volume,
"DescribeVolumes": describe_volumes,
"DetachVolume": detach_volume,
"GetCallerIdentity": get_caller_identity,
"DescribeInstanceTypes": describe_instance_types,
"DescribeTags": describe_tags,
"CreateTags": create_tags,
"DescribeInstanceAttribute": describe_instance_attribute,
}.get(action)
if action_function is None:
print(f"Unknown action: '{action}'")
raise HTTPException(
status_code=404,
detail="Unknown action",
)
return action_function(query_params, config, db, user)
@app.get("/private/cloudinit/{instance_id}/{entry}") @app.get("/private/cloudinit/{instance_id}/{entry}")
def cloud_init_data(instance_id: str, entry: str, db: DatabaseDep): def cloud_init_data(instance_id: str, entry: str, db: DatabaseDep):
@@ -47,7 +124,9 @@ def cloud_init_data(instance_id: str, entry: str, db: DatabaseDep):
raise HTTPException(status_code=404, detail="Unknown cloud-init file") raise HTTPException(status_code=404, detail="Unknown cloud-init file")
if entry == "user-data": if entry == "user-data":
instance = db.exec(select(Instance).where(Instance.id == instance_id)).first()[0] instance = db.exec(select(Instance).where(Instance.id == instance_id)).first()[
0
]
if instance is None: if instance is None:
raise HTTPException(status_code=404, detail="Unknown instance") raise HTTPException(status_code=404, detail="Unknown instance")
@@ -58,17 +137,21 @@ def cloud_init_data(instance_id: str, entry: str, db: DatabaseDep):
media_type="application/yaml", media_type="application/yaml",
) )
elif entry == "meta-data": elif entry == "meta-data":
return multiline_yaml_response([ return multiline_yaml_response(
[
f"instance-id: {instance_id}", f"instance-id: {instance_id}",
f"local-hostname: {instance_id}", f"local-hostname: {instance_id}",
]) ]
)
elif entry == "vendor-data": elif entry == "vendor-data":
return multiline_yaml_response([ return multiline_yaml_response(
[
"#cloud-config", "#cloud-config",
"growpart:", "growpart:",
" devices: [/]", " devices: [/]",
" ignore_growroot_disabled: true", " ignore_growroot_disabled: true",
]) ]
)
elif entry == "network-config": elif entry == "network-config":
return Response( return Response(
"", "",

View File

View File

@@ -0,0 +1,13 @@
import subprocess
from openec2.db.vpc import VPC
def prepare_host_vpc(vpc: VPC):
# Create the bridge
subprocess.call([
"/home/alexander/Development/Personal/openec2/scripts/create-network-interface.sh",
vpc.virbr,
vpc.gateway,
vpc.broadcast,
])

192
src/openec2/security/aws.py Normal file
View File

@@ -0,0 +1,192 @@
from typing import Annotated, cast
from dataclasses import dataclass
import datetime
from hashlib import sha256
from urllib.parse import parse_qs
from sqlmodel import select
from fastapi import Request, HTTPException, Depends
from fastapi.datastructures import URL, Headers
from cryptography.hazmat.primitives import hashes, hmac
from openec2.config import ConfigSingleton
from openec2.db import DatabaseDep
from openec2.db.user import User
def _hmac_sha256(key: bytes, payload: bytes) -> bytes:
h = hmac.HMAC(key, hashes.SHA256())
h.update(payload)
return h.finalize()
@dataclass
class AWSRequest:
# The entire used URL
url: URL
# Request method
method: str
# Query params
params: dict[str, str]
# Request headers
headers: Headers
# The payload, if we used a POST/PUT
payload: str | None
# List of headers that were signed
signed_headers: list[str]
def include_in_canonical_string(self, name: str) -> bool:
return name.lower() in self.signed_headers
# TODO: Probably vulnerable against a replay because we never get the current date ourselves
def sign(
self, secret_access_key: str, region: str, product: str, credential_scope: str
) -> str:
dt = datetime.datetime.fromisoformat(self.headers["X-Amz-Date"])
canonical_header_string_keys = sorted(
[name for name in self.headers.keys() if self.include_in_canonical_string(name)]
)
canonical_header_string = (
"\n".join(
[
f"{name.lower()}:{self.headers[name].strip()}"
for name in canonical_header_string_keys
]
)
+ "\n"
)
signed_headers = ";".join(canonical_header_string_keys)
hashed_payload = sha256((self.payload or "").encode()).hexdigest()
canonical_request = "\n".join(
[
self.method.upper(),
self.url.path or "/",
# canonical_query_string,
"",
canonical_header_string,
signed_headers,
hashed_payload,
]
)
hashed_canonical_request = sha256(canonical_request.encode()).hexdigest()
date = dt.strftime("%Y%m%d")
string_to_sign = "\n".join(
[
"AWS4-HMAC-SHA256",
dt.strftime("%Y%m%dT%H%M%SZ"),
credential_scope,
hashed_canonical_request,
]
)
date_key = _hmac_sha256(f"AWS4{secret_access_key}".encode(), date.encode())
date_region_key = _hmac_sha256(date_key, region.encode())
date_region_service_key = _hmac_sha256(date_region_key, product.encode())
signing_key = _hmac_sha256(date_region_service_key, "aws4_request".encode())
return _hmac_sha256(signing_key, string_to_sign.encode()).hex()
@dataclass
class AWSAuthentication:
x_amz_algorithm: str
x_amz_credential: str
x_amz_signature: str
signed_headers: str
def get_authentication_info(request: Request) -> AWSAuthentication:
if request.method == "POST":
algorithm, rest = request.headers["Authorization"].split(" ", 1)
auth = {}
for part in rest.split(","):
key, value = part.split("=", 1)
auth[key.strip()] = value.strip()
return AWSAuthentication(
x_amz_algorithm=algorithm,
x_amz_credential=auth["Credential"],
x_amz_signature=auth["Signature"],
signed_headers=auth["SignedHeaders"],
)
return AWSAuthentication(
"",
"",
"",
"",
)
async def check_request_signature(request: Request, db: DatabaseDep):
# Do not check if we don't care
if ConfigSingleton.of().config.insecure:
return
body = (await request.body()).decode()
query_params = (
cast(dict, parse_qs(body))
if request.method == "POST"
else cast(dict, request.query_params)
)
auth_info = get_authentication_info(request)
if auth_info.x_amz_algorithm != "AWS4-HMAC-SHA256":
raise HTTPException(
status_code=403,
detail=f"Invalid signature algorithm: {auth_info.x_amz_algorithm}",
)
x_amz_credential = auth_info.x_amz_credential
reversed_parts = x_amz_credential[::-1].split("/", 4)
access_key, date, region, service, key = [s[::-1] for s in reversed_parts][::-1]
user = db.exec(select(User).where(User.access_key == access_key)).first()
if user is None:
raise HTTPException(status_code=403)
# Validate the signed headers
signed_headers = auth_info.signed_headers.split(";")
if any(header not in signed_headers for header in [
"host",
"content-type",
]):
print("Validation of signed headers failed!")
raise HTTPException(status_code=403)
print(auth_info.x_amz_credential)
x_amz_signature = auth_info.x_amz_signature
signature = AWSRequest(
url=request.url,
method=request.method,
params=query_params,
headers=request.headers,
payload=body,
signed_headers=signed_headers,
).sign(
user.secret_access_key,
region,
service,
"/".join([date, region, service, key]),
)
if x_amz_signature != signature:
print("Signature mismatch!")
print(f"Expected: {signature}")
print(f"Got: {x_amz_signature}")
raise HTTPException(status_code=403)
return user
AWSSignature = Annotated[User, Depends(check_request_signature)]

View File

@@ -1,3 +1,6 @@
from typing import Callable
def parse_array_objects(prefix: str, params: dict) -> list[dict[str, str]]: def parse_array_objects(prefix: str, params: dict) -> list[dict[str, str]]:
items: dict[str, dict[str, str]] = {} items: dict[str, dict[str, str]] = {}
for key, value in params.items(): for key, value in params.items():
@@ -10,6 +13,7 @@ def parse_array_objects(prefix: str, params: dict) -> list[dict[str, str]]:
items[parts[1]][".".join(parts[2:])] = value items[parts[1]][".".join(parts[2:])] = value
return list(items.values()) return list(items.values())
def parse_array_plain(prefix: str, params: dict[str, str]) -> list[str]: def parse_array_plain(prefix: str, params: dict[str, str]) -> list[str]:
items: dict[str, str] = {} items: dict[str, str] = {}
for key, value in params.items(): for key, value in params.items():
@@ -24,3 +28,18 @@ def parse_array_plain(prefix: str, params: dict[str, str]) -> list[str]:
indices = sorted(list(items.keys())) indices = sorted(list(items.keys()))
return [items[key] for key in indices] return [items[key] for key in indices]
def find[T](l: list[T], pred: Callable[[T], bool]) -> T | None:
for item in l:
if pred(item):
return item
return None
def parse_tag_specification(params: dict) -> dict[str, str]:
tags: dict[str, str] = {}
for spec in parse_array_objects("TagSpecification", params):
for raw_tag in parse_array_objects("Tag", spec):
tags[raw_tag["Key"]] = raw_tag["Value"]
return tags

View File

@@ -9,9 +9,10 @@ from openec2.db.instance import Instance
def ipv4_to_int(ip: str) -> int: def ipv4_to_int(ip: str) -> int:
i = 0 i = 0
for idx, p in enumerate(ip.split(".")): for idx, p in enumerate(ip.split(".")):
i += (int(p) << (3-idx)*8) i += int(p) << (3 - idx) * 8
return i return i
def int_to_ipv4(ip: int) -> str: def int_to_ipv4(ip: int) -> str:
parts: list[int] = [] parts: list[int] = []
for i in reversed(range(4)): for i in reversed(range(4)):
@@ -20,6 +21,7 @@ def int_to_ipv4(ip: int) -> str:
) )
return ".".join(str(p) for p in parts) return ".".join(str(p) for p in parts)
def generate_mac() -> str: def generate_mac() -> str:
mac_bytes = random.randbytes(6) mac_bytes = random.randbytes(6)
mac = "" mac = ""
@@ -35,10 +37,14 @@ def generate_mac() -> str:
mac += f"{h}:" mac += f"{h}:"
return mac[:-1] return mac[:-1]
def generate_available_mac(db: DatabaseDep) -> str: def generate_available_mac(db: DatabaseDep) -> str:
mac = "" mac = ""
while True: while True:
mac = generate_mac() mac = generate_mac()
if db.exec(select(Instance).where(Instance.interfaceMac == mac)).first() is None: if (
db.exec(select(Instance).where(Instance.interfaceMac == mac)).first()
is None
):
break break
return mac return mac

View File

@@ -0,0 +1,86 @@
from openec2.config import OpenEC2Config
from openec2.db.instance import EBSVolume, Instance
def ebs_volume_to_libvirt_xml(volume: EBSVolume, config: OpenEC2Config) -> str:
# TODO: Honour the attached device name
return f"""
<filesystem type='mount' accessmode='passthrough'>
<driver type='virtiofs' queue='1024' />
<source dir='{config.instances.volumes / volume.id}' />
<target dir='{volume.id}' />
</filesystem>
"""
def instance_to_libvirt_xml(
instance: Instance,
config: OpenEC2Config,
uuid: str | None = None,
) -> str:
instance_type = config.instances.types[instance.instanceType]
ami_path = config.instances.location / instance.id
memory_backing = (
"""
<memoryBacking>
<source type='memfd' />
<access mode='shared' />
</memoryBacking>
"""
if instance.ebs_volumes
else ""
)
volumes = "\n".join(
ebs_volume_to_libvirt_xml(volume, config) for volume in instance.ebs_volumes
)
uuid_element = f"<uuid>{uuid}</uuid>" if uuid is not None else ""
return f"""<domain type='kvm'>
{uuid_element}
<name>{instance.id}</name>
<memory unit='MiB'>{instance_type.memory}</memory>
{memory_backing}
<vcpu placement='static'>{int(instance_type.vcpu)}</vcpu>
<os>
<type arch='x86_64'>hvm</type>
<boot dev='hd' />
<smbios mode='sysinfo' />
</os>
<sysinfo type='smbios'>
<system>
<entry name='serial'>ds=nocloud;s=http://192.168.122.1:8000/private/cloudinit/{instance.id}/</entry>
</system>
</sysinfo>
<features>
<acpi />
<apic />
<vmport state='off' />
</features>
<clock offset='utc'>
<timer name='rtc' tickpolicy='catchup'/>
<timer name='pit' tickpolicy='delay'/>
<timer name='hpet' present='no'/>
</clock>
<pm>
<suspend-to-mem enabled='no'/>
<suspend-to-disk enabled='no'/>
</pm>
<devices>
{volumes}
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='{ami_path}'/>
<target dev='vda' bus='virtio'/>
</disk>
<rng model="virtio">
<backend model="random">/dev/urandom</backend>
</rng>
<interface type="network">
<source network="default"/>
<mac address="{instance.interfaceMac}" />
<model type="virtio"/>
</interface>
</devices>
</domain>
"""

View File

@@ -3,12 +3,17 @@ import subprocess
def create_cow_copy(src: Path, dst: Path, size: str): def create_cow_copy(src: Path, dst: Path, size: str):
subprocess.call([ subprocess.call(
[
"qemu-img", "qemu-img",
"create", "create",
"-f", "qcow2", "-f",
"-b", str(src), "qcow2",
"-F", "qcow2", "-b",
str(src),
"-F",
"qcow2",
str(dst), str(dst),
size, size,
]) ]
)

View File

@@ -1,5 +1,6 @@
from fastapi import Response from fastapi import Response
def multiline_yaml_response(lines: list[str]) -> Response: def multiline_yaml_response(lines: list[str]) -> Response:
return Response( return Response(
"\n".join(lines), "\n".join(lines),

View File

@@ -14,6 +14,7 @@ def test_array_parsing_keys():
assert parsed[1]["a"] == "3" assert parsed[1]["a"] == "3"
assert parsed[1]["b"] == "4" assert parsed[1]["b"] == "4"
def test_array_plain_parsing(): def test_array_plain_parsing():
params = { params = {
"Key.1": "1", "Key.1": "1",

101
uv.lock generated
View File

@@ -33,6 +33,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
] ]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.1" version = "3.4.1"
@@ -76,6 +98,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
] ]
[[package]]
name = "cryptography"
version = "44.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 },
{ url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 },
{ url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 },
{ url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 },
{ url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 },
{ url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 },
{ url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 },
{ url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 },
{ url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 },
{ url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 },
{ url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 },
{ url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 },
{ url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 },
{ url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 },
{ url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 },
{ url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 },
{ url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 },
{ url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 },
{ url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 },
{ url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 },
{ url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 },
{ url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 },
{ url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 },
{ url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 },
]
[[package]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.7.0" version = "2.7.0"
@@ -307,6 +364,7 @@ name = "openec2"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cryptography" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "libvirt-python" }, { name = "libvirt-python" },
{ name = "pydantic-xml" }, { name = "pydantic-xml" },
@@ -315,8 +373,14 @@ dependencies = [
{ name = "sqlmodel" }, { name = "sqlmodel" },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "cryptography", specifier = ">=44.0.2" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
{ name = "libvirt-python", specifier = ">=11.1.0" }, { name = "libvirt-python", specifier = ">=11.1.0" },
{ name = "pydantic-xml", specifier = ">=2.14.3" }, { name = "pydantic-xml", specifier = ">=2.14.3" },
@@ -325,6 +389,9 @@ requires-dist = [
{ name = "sqlmodel", specifier = ">=0.0.24" }, { name = "sqlmodel", specifier = ">=0.0.24" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.11.2" }]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "24.2" version = "24.2"
@@ -343,6 +410,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
] ]
[[package]]
name = "pycparser"
version = "2.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.11.0" version = "2.11.0"
@@ -500,6 +576,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/a2/dc0ae0b61d5fce9eec3763c98d5a471f7b07c891a2cbfb3fd6a0f632a9a1/rich_toolkit-0.14.0-py3-none-any.whl", hash = "sha256:75ff4b3e70e27e9cb145164bfe8d8e56758162fa3f87594067f4d85630b98bf9", size = 24062 }, { url = "https://files.pythonhosted.org/packages/70/a2/dc0ae0b61d5fce9eec3763c98d5a471f7b07c891a2cbfb3fd6a0f632a9a1/rich_toolkit-0.14.0-py3-none-any.whl", hash = "sha256:75ff4b3e70e27e9cb145164bfe8d8e56758162fa3f87594067f4d85630b98bf9", size = 24062 },
] ]
[[package]]
name = "ruff"
version = "0.11.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 },
{ url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 },
{ url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 },
{ url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 },
{ url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 },
{ url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 },
{ url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 },
{ url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 },
{ url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 },
{ url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 },
{ url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 },
{ url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 },
{ url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 },
{ url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 },
{ url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 },
{ url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 },
{ url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 },
]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"