Compare commits

...

5 Commits

34 changed files with 708 additions and 152 deletions

View File

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

View File

@ -4,19 +4,26 @@ from sqlmodel import select
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.image import AMI
from openec2.images import garbage_collect_image
def deregister_image(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
image_id = params["ImageId"]
ami = db.exec(select(AMI).where(AMI.id == image_id)).one()
if ami is None:
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
ami.deregistered = True
db.add(ami)

View File

@ -6,6 +6,7 @@ from sqlmodel import select
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.image import AMI
from openec2.api.describe_images import DescribeImagesResponse, ImagesSet, Image
@ -14,6 +15,7 @@ def describe_images(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
_: User,
):
images: list[Image] = []
for ami in db.exec(select(AMI)).all():
@ -30,7 +32,7 @@ def describe_images(
requestId=uuid.uuid4().hex,
imagesSet=ImagesSet(
items=images,
)
),
).to_xml(),
media_type="application/xml",
)

View File

@ -5,23 +5,36 @@ from fastapi.datastructures import QueryParams
from sqlmodel import select
from openec2.libvirt import LibvirtSingleton
from openec2.api.describe_instances import InstanceDescription, DescribeInstancesResponse, DescribeInstancesResponseReservationSet, describe_instance
from openec2.api.describe_instances import (
InstanceDescription,
DescribeInstancesResponse,
DescribeInstancesResponseReservationSet,
ReservationSet,
ReservationSetInstancesSet,
describe_instance,
)
from openec2.db.user import User
from openec2.api.shared import InstanceState
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.instance import Instance
def describe_instances(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
response_items: list[InstanceDescription] = []
conn = LibvirtSingleton.of().connection
for instance in db.exec(select(Instance)).all():
dom = conn.lookupByName(instance.id)
running = dom.isActive()
# Check for permission issues
if instance.owner_id != user.id:
# TODO: Add the error to the response
continue
dom = conn.lookupByName(instance.id)
response_items.append(
describe_instance(instance, dom),
)
@ -30,7 +43,15 @@ def describe_instances(
DescribeInstancesResponse(
request_id=uuid.uuid4().hex,
reservation_set=DescribeInstancesResponseReservationSet(
item=response_items,
item=[
ReservationSet(
reservationId=instance.instance_id,
instancesSet=ReservationSetInstancesSet(
item=[instance],
),
)
for instance in response_items
],
),
).to_xml(),
media_type="application/xml",

View File

@ -4,17 +4,21 @@ from urllib.parse import urlparse
import uuid
import shutil
from fastapi import HTTPException
from fastapi.datastructures import QueryParams
import requests
from openec2.config import OpenEC2Config
from openec2.config import OpenEC2Config, ConfigSingleton
from openec2.db import DatabaseDep
from openec2.db.user import User
from openec2.db.image import AMI
def import_image(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
first_disk_image_url = params["DiskContainer.1.Url"]
url = urlparse(first_disk_image_url)
@ -32,6 +36,11 @@ def import_image(
for chunk in r.iter_content(8196):
f.write(chunk)
case "file":
if not ConfigSingleton.of().config.debug:
raise HTTPException(
status_code=400,
detail="Unsupported scheme",
)
shutil.copy(
url.path,
str(dst),
@ -44,7 +53,8 @@ def import_image(
id=ami_id,
description=None,
originalFilename=filename,
)
owner_id=user.id,
),
)
db.commit()
return ami_id

View File

@ -2,7 +2,7 @@ import base64
from typing import cast
import uuid
from fastapi import HTTPException
from fastapi import HTTPException, Response
from fastapi.datastructures import QueryParams
from sqlmodel import select
@ -12,12 +12,18 @@ from openec2.utils.qemu import create_cow_copy
from openec2.db import DatabaseDep
from openec2.db.instance import Instance
from openec2.db.image import AMI
from openec2.db.user import User
from openec2.api.run_instances import RunInstanceResponse, RunInstanceInstanceSet
from openec2.api.describe_instances import describe_instance
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
def create_libvirt_domain(
name: str,
memory: int,
@ -84,17 +90,19 @@ def create_libvirt_domain(
</domain>
"""
def run_instances(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
image_id = params["ImageId"]
instance_type = params["InstanceType"]
instance_type = config.instances.types.get(params["InstanceType"])
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()
if ami is None:
@ -140,9 +148,12 @@ def run_instances(
id=instance_id,
imageId=image_id,
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,
interfaceMac=mac,
owner_id=user.id,
)
db.add(instance)
print("Inserted new instance")
@ -161,9 +172,12 @@ def run_instances(
description = describe_instance(instance, domain)
db.commit()
return RunInstanceResponse(
request_id=uuid.uuid4().hex,
instance_set=RunInstanceInstanceSet(
item=[description],
),
).to_xml()
return Response(
RunInstanceResponse(
request_id=uuid.uuid4().hex,
instance_set=RunInstanceInstanceSet(
item=[description],
),
).to_xml(),
media_type="application/xml",
)

View File

@ -1,6 +1,6 @@
import uuid
from fastapi import HTTPException
from fastapi import HTTPException, Response
from fastapi.datastructures import QueryParams
from sqlmodel import select
@ -8,48 +8,51 @@ from openec2.libvirt import LibvirtSingleton
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.instance import Instance
from openec2.db.user import User
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(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
conn = LibvirtSingleton.of().connection
instances: list[StartInstancesResponseInstancesSetInstance] = []
instances: list[InstanceInfo] = []
for instance_id in parse_array_plain("InstanceId", params):
instance = db.exec(select(Instance).where(Instance.id == instance_id)).first()
if instance is None:
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()
prev_state = InstanceState(
code=16 if running else 80,
name="running" if running else "stopped",
)
dom = conn.lookupByName(instance_id)
prev_state = describe_instance_state(dom)
if not dom.isActive():
dom.create()
running = dom.isActive()
current_state = InstanceState(
code=16 if running else 80,
name="running" if running else "stopped",
)
current_state = describe_instance_state(dom)
instances.append(
StartInstancesResponseInstancesSetInstance(
instance_id=instance_id,
previous_state=prev_state,
current_state=current_state,
InstanceInfo(
instanceId=instance_id,
previousState=prev_state,
currentState=current_state,
),
)
return StartInstancesResponse(
request_id=uuid.uuid4().hex,
instances_set=StartInstancesResponseInstancesSet(
item=instances,
),
).to_xml()
return Response(
StartInstancesResponse(
request_id=uuid.uuid4().hex,
instancesSet=InstancesSet(
item=instances,
),
).to_xml(),
media_type="application/xml",
)

View File

@ -1,6 +1,6 @@
import uuid
from fastapi import HTTPException
from fastapi import HTTPException, Response
from fastapi.datastructures import QueryParams
from sqlmodel import select
@ -8,48 +8,53 @@ from openec2.libvirt import LibvirtSingleton
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.instance import Instance
from openec2.db.user import User
from openec2.utils.array import parse_array_plain
from openec2.api.shared import InstanceState
from openec2.api.stop_instances import StopInstancesResponse, StopInstancesResponseInstancesSet, StopInstancesResponseInstancesSetInstance
from openec2.api.shared import InstanceInfo, InstancesSet
from openec2.api.describe_instances import describe_instance_state
from openec2.api.stop_instances import StopInstancesResponse
def stop_instances(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
conn = LibvirtSingleton.of().connection
instances: list[StopInstancesResponseInstancesSetInstance] = []
instances: list[InstanceInfo] = []
for instance_id in parse_array_plain("InstanceId", params):
instance = db.exec(select(Instance).where(Instance.id == instance_id)).first()
if instance is None:
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)
running = dom.isActive()
prev_state = InstanceState(
code=16 if running else 80,
name="running" if running else "stopped",
)
prev_state = describe_instance_state(dom)
if running:
dom.shutdown()
running = dom.isActive()
current_state = InstanceState(
code=16 if running else 80,
name="running" if running else "stopped",
)
current_state = describe_instance_state(dom)
instances.append(
StopInstancesResponseInstancesSetInstance(
instance_id=instance_id,
previous_state=prev_state,
current_state=current_state,
InstanceInfo(
instanceId=instance_id,
previousState=prev_state,
currentState=current_state,
),
)
return StopInstancesResponse(
request_id=uuid.uuid4().hex,
instances_set=StopInstancesResponseInstancesSet(
item=instances,
),
).to_xml()
return Response(
StopInstancesResponse(
requestId=uuid.uuid4().hex,
instancesSet=InstancesSet(
item=instances,
),
).to_xml(),
media_type="application/xml",
)

View File

@ -1,6 +1,7 @@
import logging
import uuid
from fastapi import HTTPException
from fastapi import HTTPException, Response
from fastapi.datastructures import QueryParams
from sqlmodel import select
@ -8,37 +9,62 @@ from openec2.libvirt import LibvirtSingleton
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep
from openec2.db.instance import Instance
from openec2.db.user import User
from openec2.utils.array import parse_array_plain
from openec2.images import garbage_collect_image
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()
def terminate_instances(
params: QueryParams,
config: OpenEC2Config,
db: DatabaseDep,
user: User,
):
instances: list[InstanceInfo] = []
conn = LibvirtSingleton.of().connection
image_ids: set[str] = set()
for instance_id in parse_array_plain("InstanceId", params):
instance = db.exec(select(Instance).where(Instance.id == instance_id)).first()
if instance is None:
raise HTTPException(status_code=404, detail="Unknown instance")
continue
# 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)
prev_state = describe_instance_state(dom)
if dom.isActive():
dom.shutdown()
current_state = describe_instance_state(dom)
dom.undefine()
instances.append(
InstanceInfo(
instanceId=instance.id,
currentState=current_state,
previousState=prev_state,
),
)
# Delete files
logger.debug(f"Removing {config.instances.location / instance_id}")
instance_disk = config.instances.location / instance_id
instance_disk.unlink()
image_ids.add(instance.imageId)
remove_instance_dhcp_mapping(instance.id, instance.interfaceMac, instance.privateIPv4, db)
remove_instance_dhcp_mapping(
instance.id, instance.interfaceMac, instance.privateIPv4, db
)
db.delete(instance)
db.commit()
@ -47,4 +73,12 @@ def terminate_instances(
for image_id in image_ids:
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

@ -1,5 +1,6 @@
from pydantic_xml import BaseXmlModel, element
class Image(BaseXmlModel):
imageId: str = element()
@ -7,13 +8,15 @@ class Image(BaseXmlModel):
name: str = element()
class ImagesSet(BaseXmlModel, tag="imagesSet"):
items: list[Image] = element(tag="item")
class DescribeImagesResponse(
BaseXmlModel,
tag="DescribeImagesResponse",
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"}
nsmap={"": "http://ec2.amazonaws.com/doc/2016-11-15/"},
):
requestId: str = element()

View File

@ -9,9 +9,11 @@ class InstanceTag(BaseXmlModel):
key: str = element()
value: str = element()
class InstanceTagSet(BaseXmlModel):
item: list[InstanceTag] = element()
class InstanceDescription(
BaseXmlModel,
tag="item",
@ -21,36 +23,54 @@ class InstanceDescription(
instance_state: InstanceState = element(tag="instanceState")
tag_set: InstanceTagSet = element(tag="tagSet")
class ReservationSetInstancesSet(BaseXmlModel):
item: list[InstanceDescription] = element()
class ReservationSet(BaseXmlModel):
reservationId: str = element()
instancesSet: ReservationSetInstancesSet = element()
class DescribeInstancesResponseReservationSet(
BaseXmlModel,
tag="reservationSet",
):
item: list[InstanceDescription] = element()
item: list[ReservationSet] = element("")
class DescribeInstancesResponse(
BaseXmlModel,
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")
reservation_set: DescribeInstancesResponseReservationSet = element("reservationSet")
def describe_instance(instance: Instance, domain: libvirt.virDomain) -> InstanceDescription:
def describe_instance_state(domain: libvirt.virDomain) -> InstanceState:
running = domain.isActive()
return InstanceState(
code=16 if running else 80,
name="running" if running else "stopped",
)
def describe_instance(
instance: Instance, domain: libvirt.virDomain
) -> InstanceDescription:
return InstanceDescription(
instance_id=instance.id,
image_id=instance.imageId,
instance_state=InstanceState(
code=16 if running else 80,
name="running" if running else "stopped",
),
instance_state=describe_instance_state(domain),
tag_set=InstanceTagSet(
item=[
InstanceTag(
key=key,
value=value,
) for key, value in instance.tags.items()
)
for key, value in instance.tags.items()
],
),
)

View File

@ -1,12 +1,12 @@
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
class RunInstanceInstanceSet(BaseXmlModel):
item: list[InstanceDescription] = element()
class RunInstanceResponse(BaseXmlModel):
request_id: str = element(tag="requestId")

View File

@ -4,3 +4,13 @@ from pydantic_xml import BaseXmlModel, element
class InstanceState(BaseXmlModel):
code: int = 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()

View File

@ -1,26 +1,12 @@
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(
BaseXmlModel,
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")
instances_set: StartInstancesResponseInstancesSet
instancesSet: InstancesSet = element()

View File

@ -1,26 +1,12 @@
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(
BaseXmlModel,
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")
instances_set: StopInstancesResponseInstancesSet
requestId: str = element(tag="requestId")
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 typing import Annotated
class _OpenEC2InstanceType(BaseModel):
memory: int # MiB
memory: int # MiB
vcpu: float
disk: int # Gi
disk: int # Gi
class _OpenEC2InstanceConfig(BaseModel):
location: Path
types: dict[str, _OpenEC2InstanceType]
class _OpenEC2LibvirtConfig(BaseModel):
connection: str
class _OpenEC2DatabaseConfig(BaseModel):
# DB URL for sqlmodel
url: str
@ -23,6 +27,7 @@ class _OpenEC2DatabaseConfig(BaseModel):
# Print SQL statements
debug: bool
class _OpenEC2Config(BaseModel):
images: Path
seed: Path
@ -30,6 +35,8 @@ class _OpenEC2Config(BaseModel):
libvirt: _OpenEC2LibvirtConfig
database: _OpenEC2DatabaseConfig
debug: bool
insecure: bool
def _get_config() -> _OpenEC2Config:
# TODO: Read from disk
@ -46,16 +53,16 @@ def _get_config() -> _OpenEC2Config:
),
},
),
libvirt=_OpenEC2LibvirtConfig(
connection="qemu:///system"
),
libvirt=_OpenEC2LibvirtConfig(connection="qemu:///system"),
debug=True,
insecure=False,
database=_OpenEC2DatabaseConfig(
url="sqlite:////home/alexander/openec2/db2.sqlite3",
debug=True,
),
)
class ConfigSingleton:
__instance: "ConfigSingleton | None" = None
@ -73,4 +80,5 @@ class ConfigSingleton:
ConfigSingleton.__instance = ConfigSingleton()
return ConfigSingleton.__instance
OpenEC2Config = Annotated[_OpenEC2Config, Depends(ConfigSingleton.of().get_config)]

View File

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

View File

@ -1,5 +1,6 @@
from sqlmodel import SQLModel, Field
class AMI(SQLModel, table=True):
# ID of the AMI
id: str = Field(default=None, primary_key=True)
@ -12,3 +13,6 @@ class AMI(SQLModel, table=True):
# Was the image registered
deregistered: bool = Field(default=False)
# Owner of the image who created it
owner_id: int = Field(foreign_key="user.id")

View File

@ -1,5 +1,6 @@
from sqlmodel import SQLModel, Field, JSON, Column
class Instance(SQLModel, table=True):
id: str = Field(default=None, primary_key=True)
@ -17,3 +18,6 @@ class Instance(SQLModel, table=True):
# Private IPv4 of the instance
privateIPv4: str
# The owner that creatd the resource.
owner_id: int = Field(foreign_key="user.id")

View File

@ -19,6 +19,4 @@ class IPAMEntry(SQLModel, table=True):
def set_ipv4(self, addr: str):
self.ipv4_addr_raw = ipv4_to_int(addr)
__table_args = (
PrimaryKeyConstraint("ipv4_addr_raw", "vpc_id"),
)
__table_args = (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,5 +1,6 @@
from sqlmodel import SQLModel, Field, PrimaryKeyConstraint
class VPC(SQLModel, table=True):
# ID of the VPC
id: str = Field(default=None, primary_key=True)

View File

@ -13,7 +13,9 @@ def garbage_collect_image(image_id: str, db: DatabaseDep):
print(instances)
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:
db.delete(ami)
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,
)
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)
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)
# 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,
)
def is_ipv4_available(ipv4: str, db: DatabaseDep) -> bool:
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:
entries = db.exec(select(IPAMEntry)).all()
# 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
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 sqlalchemy import select
from sqlmodel import SQLModel
from openec2.security.aws import AWSSignature
from openec2.utils.text import multiline_yaml_response
from openec2.config import OpenEC2Config
from openec2.db import DatabaseDep, engine
from openec2.db.user import User
from openec2.actions.describe_images import describe_images
from openec2.actions.import_image import import_image
from openec2.actions.describe_instances import describe_instances
@ -17,19 +22,58 @@ from openec2.db.instance import Instance
app = FastAPI()
@app.on_event("startup")
def on_startup():
SQLModel.metadata.create_all(engine)
@app.get("/healthz", response_model=None)
def healthz():
return {
"status": "OK",
}
@app.get("/Action", response_model=None)
def action(request: Request, config: OpenEC2Config, db: DatabaseDep):
action = request.query_params["Action"]
def get_action(
request: Request, config: OpenEC2Config, db: DatabaseDep, user: AWSSignature
):
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
):
query_params = {
key: value[0]
for key, value in parse_qs((await request.body()).decode()).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,
):
print(query_params)
action = query_params["Action"]
return {
"ImportImage": import_image,
"DescribeImages": describe_images,
@ -39,7 +83,8 @@ def action(request: Request, config: OpenEC2Config, db: DatabaseDep):
"StartInstances": start_instances,
"StopInstances": stop_instances,
"DeregisterImage": deregister_image,
}[action](request.query_params, config, db)
}[action](query_params, config, db, user)
@app.get("/private/cloudinit/{instance_id}/{entry}")
def cloud_init_data(instance_id: str, entry: str, db: DatabaseDep):
@ -47,7 +92,9 @@ def cloud_init_data(instance_id: str, entry: str, db: DatabaseDep):
raise HTTPException(status_code=404, detail="Unknown cloud-init file")
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:
raise HTTPException(status_code=404, detail="Unknown instance")
@ -58,17 +105,21 @@ def cloud_init_data(instance_id: str, entry: str, db: DatabaseDep):
media_type="application/yaml",
)
elif entry == "meta-data":
return multiline_yaml_response([
f"instance-id: {instance_id}",
f"local-hostname: {instance_id}",
])
return multiline_yaml_response(
[
f"instance-id: {instance_id}",
f"local-hostname: {instance_id}",
]
)
elif entry == "vendor-data":
return multiline_yaml_response([
"#cloud-config",
"growpart:",
" devices: [/]",
" ignore_growroot_disabled: true",
])
return multiline_yaml_response(
[
"#cloud-config",
"growpart:",
" devices: [/]",
" ignore_growroot_disabled: true",
]
)
elif entry == "network-config":
return Response(
"",

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

@ -0,0 +1,178 @@
from typing import Annotated, cast
from dataclasses import dataclass
import datetime
from hashlib import sha256
from urllib.parse import quote, 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
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 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,
]
)
print("Canonical request")
print(canonical_request)
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,
]
)
print("String to sign")
print(string_to_sign)
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
def include_in_canonical_string(name: str) -> bool:
lower = name.lower()
return lower in ("host", "content-type") or lower.startswith("x-amz")
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"],
)
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=400,
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)
x_amz_signature = auth_info.x_amz_signature
signature = AWSRequest(
url=request.url,
method=request.method,
params=query_params,
headers=request.headers,
payload=body,
).sign(
user.secret_access_key,
region,
service,
"/".join([date, region, service, key]),
)
print(x_amz_signature, signature)
if x_amz_signature != signature:
raise HTTPException(status_code=401)
return user
AWSSignature = Annotated[User, Depends(check_request_signature)]

View File

@ -10,6 +10,7 @@ def parse_array_objects(prefix: str, params: dict) -> list[dict[str, str]]:
items[parts[1]][".".join(parts[2:])] = value
return list(items.values())
def parse_array_plain(prefix: str, params: dict[str, str]) -> list[str]:
items: dict[str, str] = {}
for key, value in params.items():

View File

@ -9,17 +9,19 @@ from openec2.db.instance import Instance
def ipv4_to_int(ip: str) -> int:
i = 0
for idx, p in enumerate(ip.split(".")):
i += (int(p) << (3-idx)*8)
i += int(p) << (3 - idx) * 8
return i
def int_to_ipv4(ip: int) -> str:
parts: list[int] = []
for i in reversed(range(4)):
parts.append(
(ip >> i*8) & 255,
(ip >> i * 8) & 255,
)
return ".".join(str(p) for p in parts)
def generate_mac() -> str:
mac_bytes = random.randbytes(6)
mac = ""
@ -35,10 +37,14 @@ def generate_mac() -> str:
mac += f"{h}:"
return mac[:-1]
def generate_available_mac(db: DatabaseDep) -> str:
mac = ""
while True:
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
return mac

View File

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

View File

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

View File

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

101
uv.lock
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 },
]
[[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]]
name = "charset-normalizer"
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 },
]
[[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]]
name = "dnspython"
version = "2.7.0"
@ -307,6 +364,7 @@ name = "openec2"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi", extra = ["standard"] },
{ name = "libvirt-python" },
{ name = "pydantic-xml" },
@ -315,8 +373,14 @@ dependencies = [
{ name = "sqlmodel" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=44.0.2" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
{ name = "libvirt-python", specifier = ">=11.1.0" },
{ name = "pydantic-xml", specifier = ">=2.14.3" },
@ -325,6 +389,9 @@ requires-dist = [
{ name = "sqlmodel", specifier = ">=0.0.24" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.11.2" }]
[[package]]
name = "packaging"
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 },
]
[[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]]
name = "pydantic"
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 },
]
[[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]]
name = "shellingham"
version = "1.5.4"