Compare commits
9 Commits
6ee5778878
...
feature/vp
| Author | SHA1 | Date | |
|---|---|---|---|
| c1217db13a | |||
| 6d99b446a0 | |||
| 38d37a7d5b | |||
| 697c89bb4f | |||
| 3744c343d4 | |||
| 1128d73bee | |||
| 9d4867d74e | |||
| 97f3a12617 | |||
| 6b4eaa6448 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,3 +8,9 @@ wheels/
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Terraform/Tofu
|
||||
examples/tofu/**/.terraform
|
||||
examples/tofu/**/.terraform.lock.hcl
|
||||
examples/tofu/**/.terraform.tfstate
|
||||
examples/tofu/**/.terraform.tfstate.backup
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,3 +6,5 @@ users:
|
||||
groups: wheel
|
||||
plain_text_passwd: abc123
|
||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||
ssh_authorized_keys:
|
||||
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDUz3WF4qPhk01//5QUuNWyHTn8shv86i/qEyRqa1kTF alexander@miku
|
||||
61
examples/tofu/main.tf
Normal file
61
examples/tofu/main.tf
Normal 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"
|
||||
# }
|
||||
# }
|
||||
1
examples/tofu/terraform.tfstate
Normal file
1
examples/tofu/terraform.tfstate
Normal 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}
|
||||
1
examples/tofu/terraform.tfstate.backup
Normal file
1
examples/tofu/terraform.tfstate.backup
Normal file
@@ -0,0 +1 @@
|
||||
{"version":4,"terraform_version":"1.9.0","serial":7,"lineage":"a013da38-6954-7573-33dd-c05f6b0ec61f","outputs":{},"resources":[],"check_results":null}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
10
scripts/create-network-interface.sh
Normal file
10
scripts/create-network-interface.sh
Normal 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
|
||||
90
src/openec2/actions/attach_volume.py
Normal file
90
src/openec2/actions/attach_volume.py
Normal 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",
|
||||
)
|
||||
51
src/openec2/actions/create_tags.py
Normal file
51
src/openec2/actions/create_tags.py
Normal 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",
|
||||
)
|
||||
41
src/openec2/actions/create_volume.py
Normal file
41
src/openec2/actions/create_volume.py
Normal 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",
|
||||
)
|
||||
33
src/openec2/actions/create_vpc.py
Normal file
33
src/openec2/actions/create_vpc.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import uuid
|
||||
from typing import cast
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Response
|
||||
from fastapi.datastructures import QueryParams
|
||||
@@ -6,31 +8,66 @@ 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
|
||||
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(
|
||||
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("Value", filter),
|
||||
)
|
||||
)
|
||||
|
||||
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(
|
||||
Image(
|
||||
imageId=ami.id,
|
||||
imageState="available",
|
||||
name=ami.originalFilename,
|
||||
tagSet=[
|
||||
Tag(
|
||||
key=key,
|
||||
value=value,
|
||||
) for key, value in ami.tags.items()
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
return Response(
|
||||
DescribeImagesResponse(
|
||||
requestId=uuid.uuid4().hex,
|
||||
imagesSet=ImagesSet(
|
||||
items=images,
|
||||
)
|
||||
imagesSet=images,
|
||||
).to_xml(),
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
43
src/openec2/actions/describe_instance_attribute.py
Normal file
43
src/openec2/actions/describe_instance_attribute.py
Normal 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",
|
||||
)
|
||||
35
src/openec2/actions/describe_instance_types.py
Normal file
35
src/openec2/actions/describe_instance_types.py
Normal 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",
|
||||
)
|
||||
@@ -1,37 +1,87 @@
|
||||
import uuid
|
||||
from typing import cast
|
||||
import datetime
|
||||
|
||||
from fastapi import Response
|
||||
from fastapi.datastructures import QueryParams
|
||||
from sqlmodel import select
|
||||
from sqlmodel import select, or_
|
||||
|
||||
from openec2.libvirt import LibvirtSingleton
|
||||
from openec2.api.describe_instances import InstanceDescription, DescribeInstancesResponse, DescribeInstancesResponseReservationSet, describe_instance
|
||||
from openec2.api.shared import InstanceState
|
||||
from openec2.api.describe_instances import (
|
||||
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.db import DatabaseDep
|
||||
from openec2.db.instance import Instance
|
||||
from openec2.utils.array import parse_array_plain
|
||||
|
||||
|
||||
def describe_instances(
|
||||
params: QueryParams,
|
||||
config: OpenEC2Config,
|
||||
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] = []
|
||||
conn = LibvirtSingleton.of().connection
|
||||
for instance in db.exec(select(Instance)).all():
|
||||
dom = conn.lookupByName(instance.id)
|
||||
running = dom.isActive()
|
||||
now = datetime.datetime.now()
|
||||
for instance in instances:
|
||||
# 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(
|
||||
describe_instance(instance, dom),
|
||||
)
|
||||
|
||||
return Response(
|
||||
DescribeInstancesResponse(
|
||||
request_id=uuid.uuid4().hex,
|
||||
reservation_set=DescribeInstancesResponseReservationSet(
|
||||
item=response_items,
|
||||
),
|
||||
requestId=uuid.uuid4().hex,
|
||||
reservationSet=[
|
||||
ReservationSet(
|
||||
reservationId=instance.instanceId,
|
||||
instancesSet=[instance],
|
||||
)
|
||||
for instance in response_items
|
||||
],
|
||||
).to_xml(),
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
99
src/openec2/actions/describe_tags.py
Normal file
99
src/openec2/actions/describe_tags.py
Normal 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",
|
||||
)
|
||||
35
src/openec2/actions/describe_volumes.py
Normal file
35
src/openec2/actions/describe_volumes.py
Normal 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",
|
||||
)
|
||||
61
src/openec2/actions/detach_volume.py
Normal file
61
src/openec2/actions/detach_volume.py
Normal 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",
|
||||
)
|
||||
30
src/openec2/actions/get_caller_identity.py
Normal file
30
src/openec2/actions/get_caller_identity.py
Normal 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",
|
||||
)
|
||||
@@ -4,22 +4,28 @@ 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
|
||||
from openec2.utils.array import parse_tag_specification
|
||||
|
||||
|
||||
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)
|
||||
ami_id = uuid.uuid4().hex
|
||||
|
||||
tags: dict[str, str] = parse_tag_specification(cast(dict, params))
|
||||
imageLocation = cast(Path, config.images)
|
||||
imageLocation.mkdir(exist_ok=True)
|
||||
dst = imageLocation / ami_id
|
||||
@@ -32,6 +38,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 +55,9 @@ def import_image(
|
||||
id=ami_id,
|
||||
description=None,
|
||||
originalFilename=filename,
|
||||
)
|
||||
owner_id=user.id,
|
||||
tags=tags,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return ami_id
|
||||
|
||||
@@ -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_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:
|
||||
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:
|
||||
@@ -107,11 +115,11 @@ def run_instances(
|
||||
)
|
||||
|
||||
# Parse tags
|
||||
# TODO: broken
|
||||
tags: dict[str, str] = {}
|
||||
for spec in parse_array_objects("TagSpecification", cast(dict, params)):
|
||||
for raw_tag in parse_array_objects("Tag", spec):
|
||||
tags[raw_tag["Key"]] = raw_tag["Value"]
|
||||
print(f"Creating with tags {tags}")
|
||||
|
||||
# Get a private IPv4
|
||||
instance_id = uuid.uuid4().hex
|
||||
@@ -140,9 +148,15 @@ 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,
|
||||
instanceType=instance_type_name,
|
||||
owner_id=user.id,
|
||||
terminated=False,
|
||||
terminationDate=None,
|
||||
)
|
||||
db.add(instance)
|
||||
print("Inserted new instance")
|
||||
@@ -161,9 +175,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,
|
||||
instancesSet=RunInstanceInstanceSet(
|
||||
item=[description],
|
||||
),
|
||||
).to_xml(),
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.terminated == False)).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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.terminated == False)).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",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
from typing import cast
|
||||
import uuid
|
||||
import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Response
|
||||
from fastapi.datastructures import QueryParams
|
||||
from sqlmodel import select
|
||||
|
||||
@@ -8,38 +11,65 @@ 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()
|
||||
instance = db.exec(select(Instance).where(Instance.id == instance_id, Instance.owner_id == user.id)).first()
|
||||
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)
|
||||
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)
|
||||
db.delete(instance)
|
||||
for volume in instance.ebs_volumes:
|
||||
volume.instances.remove(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()
|
||||
|
||||
@@ -47,4 +77,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",
|
||||
)
|
||||
|
||||
17
src/openec2/api/attach_volume.py
Normal file
17
src/openec2/api/attach_volume.py
Normal 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()
|
||||
10
src/openec2/api/create_tags.py
Normal file
10
src/openec2/api/create_tags.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic_xml import BaseXmlModel, element
|
||||
|
||||
|
||||
class CreateTagsResponse(
|
||||
BaseXmlModel,
|
||||
nsmap={"": ""},
|
||||
):
|
||||
requestId: str = element()
|
||||
|
||||
return_: bool = element(tag="return")
|
||||
15
src/openec2/api/create_volume.py
Normal file
15
src/openec2/api/create_volume.py
Normal 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()
|
||||
1
src/openec2/api/create_vpc.py
Normal file
1
src/openec2/api/create_vpc.py
Normal file
@@ -0,0 +1 @@
|
||||
from pydantic_xml import BaseXmlModel, wrapped, element
|
||||
@@ -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):
|
||||
imageId: str = element()
|
||||
@@ -7,14 +10,14 @@ class Image(BaseXmlModel):
|
||||
|
||||
name: str = element()
|
||||
|
||||
class ImagesSet(BaseXmlModel, tag="imagesSet"):
|
||||
items: list[Image] = element(tag="item")
|
||||
tagSet: list[Tag] = wrapped("tagSet", 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()
|
||||
|
||||
imagesSet: ImagesSet = element()
|
||||
imagesSet: list[Image] = wrapped("imagesSet", element(tag="item"))
|
||||
|
||||
26
src/openec2/api/describe_instance_attribute.py
Normal file
26
src/openec2/api/describe_instance_attribute.py
Normal 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)
|
||||
18
src/openec2/api/describe_instance_types.py
Normal file
18
src/openec2/api/describe_instance_types.py
Normal 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()
|
||||
@@ -1,56 +1,55 @@
|
||||
from pydantic_xml import BaseXmlModel, element
|
||||
from pydantic_xml import BaseXmlModel, wrapped, element
|
||||
import libvirt
|
||||
|
||||
from openec2.db.instance import Instance
|
||||
from openec2.api.shared import InstanceState
|
||||
|
||||
|
||||
class InstanceTag(BaseXmlModel):
|
||||
key: str = element()
|
||||
value: str = element()
|
||||
|
||||
class InstanceTagSet(BaseXmlModel):
|
||||
item: list[InstanceTag] = element()
|
||||
from openec2.api.shared import InstanceState, Tag
|
||||
|
||||
class InstanceDescription(
|
||||
BaseXmlModel,
|
||||
tag="item",
|
||||
):
|
||||
instance_id: str = element(tag="instanceId")
|
||||
image_id: str = element(tag="imageId")
|
||||
instance_state: InstanceState = element(tag="instanceState")
|
||||
tag_set: InstanceTagSet = element(tag="tagSet")
|
||||
instanceId: str = element()
|
||||
imageId: str = element()
|
||||
instanceState: InstanceState = element()
|
||||
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(
|
||||
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")
|
||||
requestId: str = element()
|
||||
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()
|
||||
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",
|
||||
),
|
||||
tag_set=InstanceTagSet(
|
||||
item=[
|
||||
InstanceTag(
|
||||
key=key,
|
||||
value=value,
|
||||
) for key, value in instance.tags.items()
|
||||
],
|
||||
),
|
||||
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(
|
||||
instanceId=instance.id,
|
||||
imageId=instance.imageId,
|
||||
instanceState=describe_instance_state(domain),
|
||||
privateIpAddress=instance.privateIPv4,
|
||||
tagSet=[
|
||||
Tag(
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
for key, value in instance.tags.items()
|
||||
],
|
||||
)
|
||||
|
||||
22
src/openec2/api/describe_tags.py
Normal file
22
src/openec2/api/describe_tags.py
Normal 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"))
|
||||
20
src/openec2/api/describe_volumes.py
Normal file
20
src/openec2/api/describe_volumes.py
Normal 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()
|
||||
15
src/openec2/api/detach_volume.py
Normal file
15
src/openec2/api/detach_volume.py
Normal 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()
|
||||
18
src/openec2/api/get_caller_identity.py
Normal file
18
src/openec2/api/get_caller_identity.py
Normal 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()
|
||||
@@ -1,13 +1,13 @@
|
||||
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")
|
||||
|
||||
instance_set: RunInstanceInstanceSet = element(tag="instanceSet")
|
||||
instancesSet: RunInstanceInstanceSet = element(tag="instancesSet")
|
||||
|
||||
@@ -4,3 +4,18 @@ 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()
|
||||
|
||||
|
||||
class Tag(BaseXmlModel):
|
||||
key: str = element()
|
||||
value: str = element()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
13
src/openec2/api/terminate_instances.py
Normal file
13
src/openec2/api/terminate_instances.py
Normal 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()
|
||||
46
src/openec2/cli/add_user.py
Normal file
46
src/openec2/cli/add_user.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
volumes: 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
|
||||
@@ -38,6 +45,7 @@ def _get_config() -> _OpenEC2Config:
|
||||
seed=Path("/home/alexander/openec2/seed"),
|
||||
instances=_OpenEC2InstanceConfig(
|
||||
location=Path("/home/alexander/openec2/instances"),
|
||||
volumes=Path("/home/alexander/openec2/volumes"),
|
||||
types={
|
||||
"micro": _OpenEC2InstanceType(
|
||||
memory=1024,
|
||||
@@ -46,16 +54,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,
|
||||
debug=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ConfigSingleton:
|
||||
__instance: "ConfigSingleton | None" = None
|
||||
|
||||
@@ -73,4 +81,5 @@ class ConfigSingleton:
|
||||
ConfigSingleton.__instance = ConfigSingleton()
|
||||
return ConfigSingleton.__instance
|
||||
|
||||
|
||||
OpenEC2Config = Annotated[_OpenEC2Config, Depends(ConfigSingleton.of().get_config)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlmodel import SQLModel, Field
|
||||
from sqlmodel import SQLModel, Field, Column, JSON
|
||||
|
||||
|
||||
class AMI(SQLModel, table=True):
|
||||
# ID of the AMI
|
||||
@@ -12,3 +13,9 @@ 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")
|
||||
|
||||
# Tags associated with the AMI
|
||||
tags: dict = Field(sa_column=Column(JSON), default={})
|
||||
|
||||
@@ -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):
|
||||
id: str = Field(default=None, primary_key=True)
|
||||
@@ -6,7 +39,9 @@ class Instance(SQLModel, table=True):
|
||||
# Tags associated with the VM
|
||||
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
|
||||
|
||||
# Optional user data associated with the VM
|
||||
@@ -17,3 +52,17 @@ 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")
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
12
src/openec2/db/user.py
Normal 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
|
||||
@@ -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):
|
||||
# ID of the VPC
|
||||
id: str = Field(default=None, primary_key=True)
|
||||
|
||||
# Subnet mask
|
||||
subnet: str
|
||||
# Number-suffix of the bridge device
|
||||
bridge_num: int
|
||||
|
||||
# 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]}"
|
||||
|
||||
@@ -7,13 +7,15 @@ from openec2.db.image import AMI
|
||||
|
||||
|
||||
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:
|
||||
print("Instances sill using AMI. Not cleaning up")
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -13,24 +18,77 @@ from openec2.actions.terminate_instances import terminate_instances
|
||||
from openec2.actions.start_instances import start_instances
|
||||
from openec2.actions.stop_instances import stop_instances
|
||||
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.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.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"]
|
||||
return {
|
||||
def get_action(
|
||||
request: Request, config: OpenEC2Config, db: DatabaseDep, user: AWSSignature
|
||||
):
|
||||
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,
|
||||
"DescribeImages": describe_images,
|
||||
"RunInstances": run_instances,
|
||||
@@ -39,7 +97,26 @@ def action(request: Request, config: OpenEC2Config, db: DatabaseDep):
|
||||
"StartInstances": start_instances,
|
||||
"StopInstances": stop_instances,
|
||||
"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}")
|
||||
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")
|
||||
|
||||
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 +137,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(
|
||||
"",
|
||||
|
||||
0
src/openec2/network/dhcp.py
Normal file
0
src/openec2/network/dhcp.py
Normal file
13
src/openec2/network/vpc.py
Normal file
13
src/openec2/network/vpc.py
Normal 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
192
src/openec2/security/aws.py
Normal 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)]
|
||||
@@ -1,3 +1,6 @@
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def parse_array_objects(prefix: str, params: dict) -> list[dict[str, str]]:
|
||||
items: dict[str, dict[str, str]] = {}
|
||||
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
|
||||
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():
|
||||
@@ -24,3 +28,18 @@ def parse_array_plain(prefix: str, params: dict[str, str]) -> list[str]:
|
||||
|
||||
indices = sorted(list(items.keys()))
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
86
src/openec2/utils/libvirt.py
Normal file
86
src/openec2/utils/libvirt.py
Normal 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>
|
||||
"""
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import Response
|
||||
|
||||
|
||||
def multiline_yaml_response(lines: list[str]) -> Response:
|
||||
return Response(
|
||||
"\n".join(lines),
|
||||
|
||||
@@ -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
generated
101
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user