diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..511f86a --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,29 @@ +import libvirt +from app.orm.vm import VmORM +from dotenv import load_dotenv +from os import getenv +from sqlmodel import create_engine, SQLModel + +load_dotenv() + +db_name = getenv("POSTGRES_NAME", "distribox") +db_user = getenv("POSTGRES_USER", "distribox_user") +db_pass = getenv("POSTGRES_PASSWORD", "distribox_password") +db_port = getenv("POSTGRES_PORT", "5432") + +database_url = f"postgresql+psycopg2://{db_user}:{db_pass}@localhost:{db_port}/{db_name}" +engine = create_engine(database_url, echo=True) +SQLModel.metadata.create_all(engine) + +class QEMUConfig: + qemu_conn = None + + @classmethod + def get_connection(cls): + if cls.qemu_conn is None or cls.qemu_conn.isAlive() == 0: + try: + qemu_conn = libvirt.open("qemu:///system") + except libvirt.libvirtError: + raise + return qemu_conn + diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py new file mode 100644 index 0000000..38ef94e --- /dev/null +++ b/backend/app/core/constants.py @@ -0,0 +1,17 @@ +from pathlib import Path +import libvirt + +BASE_DIR = Path('/var/lib/distribox/') +VMS_DIR = BASE_DIR / 'vms' +IMAGES_DIR = BASE_DIR / 'images' + +VM_STATE_NAMES = { + libvirt.VIR_DOMAIN_NOSTATE: "No state", + libvirt.VIR_DOMAIN_RUNNING: "Running", + libvirt.VIR_DOMAIN_BLOCKED: "Blocked on I/O", + libvirt.VIR_DOMAIN_PAUSED: "Paused", + libvirt.VIR_DOMAIN_SHUTDOWN: "Stopping", + libvirt.VIR_DOMAIN_SHUTOFF: "Stopped", + libvirt.VIR_DOMAIN_CRASHED: "Crashed", + libvirt.VIR_DOMAIN_PMSUSPENDED: "Suspended (power management)", +} diff --git a/backend/app/core/xml_builder.py b/backend/app/core/xml_builder.py new file mode 100644 index 0000000..001a8d3 --- /dev/null +++ b/backend/app/core/xml_builder.py @@ -0,0 +1,57 @@ +from lxml import etree +from app.models.vm import VmRead +from app.core.constants import VMS_DIR + +def build_xml(vm_read: VmRead): + + domain = etree.Element("domain", type="kvm") + + etree.SubElement(domain, "name").text = str(vm_read.id) + etree.SubElement(domain, "memory", unit="MiB").text = str(vm_read.mem) + etree.SubElement(domain, "vcpu", placement="static").text = str(vm_read.vcpus) + + os_el = etree.SubElement(domain, "os") + etree.SubElement(os_el, "type", arch="x86_64", machine="pc").text = "hvm" + etree.SubElement(os_el, "boot", dev="hd") + + features = etree.SubElement(domain, "features") + for feature in ["acpi", "apic", "pae"]: + etree.SubElement(features, feature) + + etree.SubElement(domain, "cpu", mode="host-passthrough") + + etree.SubElement(domain, "clock", offset="utc") + + etree.SubElement(domain, "on_poweroff").text = "destroy" + etree.SubElement(domain, "on_reboot").text = "restart" + etree.SubElement(domain, "on_crash").text = "destroy" + + devices = etree.SubElement(domain, "devices") + + disk_main = etree.SubElement(devices, "disk", type="file", device="disk") + etree.SubElement(disk_main, "driver", name="qemu", type="qcow2") + etree.SubElement(disk_main, "source", file=str(VMS_DIR / str(vm_read.id) / f"distribox-{vm_read.os}.qcow2")) + etree.SubElement(disk_main, "target", dev="vda", bus="virtio") + + channel = etree.SubElement(devices, "channel", type="unix") + etree.SubElement(channel, "source", mode="bind") + etree.SubElement(channel, "target", type="virtio", name="org.qemu.guest_agent.0") + + # disk_seed = etree.SubElement(devices, "disk", type="file", device="cdrom") + # etree.SubElement(disk_seed, "driver", name="qemu", type="raw") + # etree.SubElement(disk_seed, "source", file="/var/lib/distribox/images/seed.iso") + # etree.SubElement(disk_seed, "target", dev="hdb", bus="ide") + # etree.SubElement(disk_seed, "readonly") + + iface = etree.SubElement(devices, "interface", type="network") + etree.SubElement(iface, "source", network="default") + etree.SubElement(iface, "model", type="virtio") + + etree.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1") + + etree.SubElement(devices, "console", type="pty") + etree.SubElement(devices, "input", type="keyboard", bus="ps2") + etree.SubElement(devices, "input", type="tablet", bus="usb") + + xml_string = etree.tostring(domain, pretty_print=True, encoding="utf-8").decode() + return xml_string diff --git a/backend/app/main.py b/backend/app/main.py index 30d4a45..3c9bd37 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,15 +1,20 @@ -from typing import Union - -from fastapi import FastAPI - +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse +from app.routes import vm app = FastAPI() -@app.get("/") -def read_root(): - return {"Hello": "World"} - +@app.exception_handler(HTTPException) +async def global_exception_handler(_, exc: HTTPException): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail} + ) -@app.get("/items/{item_id}") -def read_item(item_id: int, q: Union[str, None] = None): - return {"item_id": item_id, "q": q} +@app.exception_handler(Exception) +async def global_exception_handler(_, exc: Exception): + return JSONResponse( + status_code=500, + content={"detail": str(exc)} + ) +app.include_router(vm.router, prefix="/vms") diff --git a/backend/app/models/vm.py b/backend/app/models/vm.py new file mode 100644 index 0000000..d907020 --- /dev/null +++ b/backend/app/models/vm.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel +from typing import Optional + +class VmBase(BaseModel): + os: str + mem: int + vcpus: int + disk_size: int + +class VmRead(VmBase): + id: str + state: str + +class VmCreate(VmBase): + pass diff --git a/backend/app/orm/vm.py b/backend/app/orm/vm.py new file mode 100644 index 0000000..3a66890 --- /dev/null +++ b/backend/app/orm/vm.py @@ -0,0 +1,12 @@ +from sqlmodel import SQLModel, Field +import uuid + +class VmORM(SQLModel, table=True, ): + __tablename__ = "vms" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + os: str + mem: int + vcpus: int + disk_size: int + password: str | None = None \ No newline at end of file diff --git a/backend/app/routes/vm.py b/backend/app/routes/vm.py new file mode 100644 index 0000000..b69394d --- /dev/null +++ b/backend/app/routes/vm.py @@ -0,0 +1,39 @@ +from fastapi import status, APIRouter +from app.models.vm import VmCreate +from app.services.vm_service import VmService + +router = APIRouter() + +@router.get('/', status_code=status.HTTP_200_OK) +def get_vm_list(): + vm_list = VmService.get_vm_list() + return vm_list + +@router.get("/{vm_id}", status_code=status.HTTP_200_OK) +def get_vm(vm_id: str): + vm = VmService.get_vm(vm_id) + return vm + +@router.get("/{vm_id}/state", status_code=status.HTTP_200_OK) +def get_vm(vm_id: str): + state = VmService.get_state(vm_id) + return state + +@router.post("/{vm_id}/start", status_code=status.HTTP_200_OK) +def start_vm(vm_id: str): + vm = VmService.start_vm(vm_id) + return vm + +@router.post("/{vm_id}/stop", status_code=status.HTTP_200_OK) +def stop_vm(vm_id: str): + vm = VmService.stop_vm(vm_id) + return vm + +@router.post("/", status_code=status.HTTP_201_CREATED) +def create_vm(vm:VmCreate): + created_vm = VmService.create_vm(vm) + return created_vm + +@router.put("/{vm_id}/password", status_code=status.HTTP_200_OK) +def set_vm_password(vm_id): + return VmService.set_vm_password(vm_id) \ No newline at end of file diff --git a/backend/app/services/vm_service.py b/backend/app/services/vm_service.py new file mode 100644 index 0000000..28c34f8 --- /dev/null +++ b/backend/app/services/vm_service.py @@ -0,0 +1,172 @@ +import uuid +import shutil +import libvirt +import hashlib +from app.utils.vm import wait_for_state +from typing import Optional +from app.core.constants import VMS_DIR, IMAGES_DIR, VM_STATE_NAMES +from app.models.vm import VmCreate +from app.core.xml_builder import build_xml +from app.core.config import QEMUConfig, engine +from sqlmodel import Session, select, update +from app.orm.vm import VmORM +from fastapi import status, HTTPException + +class Vm: + def __init__(self, vm_create: VmCreate): + self.id = uuid.uuid4() + self.os = vm_create.os + self.mem = vm_create.mem + self.vcpus = vm_create.vcpus + self.disk_size = vm_create.disk_size + self.state: Optional[str] = None + + + def create(self): + if self.state : + return self + self.state = 'Stopped' + vm_dir = VMS_DIR / str(self.id) + distribox_image_dir = IMAGES_DIR / f"distribox-{self.os}.qcow2" + try: + vm_dir.mkdir(parents=True, exist_ok=True) + shutil.copy(distribox_image_dir, vm_dir) + vm_xml = build_xml(self) + conn = QEMUConfig.get_connection() + conn.defineXML(vm_xml) + with Session(engine) as session: + vm_record = VmORM( + id = self.id, + os = self.os, + mem = self.mem, + vcpus = self.vcpus, + disk_size= self.disk_size, + ) + session.add(vm_record) + session.commit() + return self + except Exception: + raise + + @classmethod + def get(cls, vm_id: str): + try: + conn = QEMUConfig.get_connection() + vm = conn.lookupByName(vm_id) + vm_state, _ = vm.state() + with Session(engine) as session: + vm_record = session.get(VmORM, uuid.UUID(vm_id)) + vm_instance = cls.__new__(cls) + vm_instance.id = vm_record.id + vm_instance.os = vm_record.os + vm_instance.mem = vm_record.mem + vm_instance.vcpus = vm_record.vcpus + vm_instance.disk_size =vm_record.disk_size + vm_instance.state = VM_STATE_NAMES.get(vm_state, 'None') + except libvirt.libvirtError as e: + if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: + raise HTTPException(status.HTTP_404_NOT_FOUND, f'Vm {vm_id} not found') + except Exception: + raise + return vm_instance + + @classmethod + def get_all(cls): + try: + with Session(engine) as session: + statement = select(VmORM) + vm_records = session.scalars(statement).all() + vm_list = [] + for vm_record in vm_records: + vm_list.append(cls.get(str(vm_record.id))) + return vm_list + except Exception: + raise + + def start(self): + try: + conn = QEMUConfig.get_connection() + vm = conn.lookupByName(str(self.id)) + if vm.isActive() == 0: + vm.create() + except libvirt.libvirtError as e: + if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: + raise HTTPException(status.HTTP_404_NOT_FOUND, f'Vm {vm.id} not found') + except Exception: + raise + state_code = wait_for_state(vm, libvirt.VIR_DOMAIN_RUNNING, 0.5, 10) + self.state=VM_STATE_NAMES.get(state_code, 'None') + return self + + def stop(self): + try: + conn = QEMUConfig.get_connection() + vm = conn.lookupByName(str(self.id)) + if vm.isActive() == 1: + vm.shutdown() + except libvirt.libvirtError as e: + if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: + raise HTTPException(status.HTTP_404_NOT_FOUND, f'Vm {vm.id} not found') + except Exception: + raise + state_code = wait_for_state(vm, libvirt.VIR_DOMAIN_SHUTOFF, 5, 10) + self.state=VM_STATE_NAMES.get(state_code, 'None') + return self + + def get_state(self): + try: + conn = QEMUConfig.get_connection() + vm = conn.lookupByName(str(self.id)) + state, _ = vm.state() + except Exception: + raise + return {"state" : VM_STATE_NAMES.get(state, 'None')} + + def generate_password(self): + try: + random_uuid = str(uuid.uuid4()) + uuid_bytes = random_uuid.encode('utf-8') + hasher = hashlib.sha256() + hasher.update(uuid_bytes) + password = hasher.hexdigest() + with Session(engine) as session: + statement = update(VmORM).where(VmORM.id == self.id).values(password=password) + session.exec(statement) + session.commit() + return {"password": password} + except Exception: + raise + + +class VmService: + + def get_vm_list(): + vm = Vm.get_all() + return vm + def get_vm(vm_id: str): + vm = Vm.get(vm_id) + return vm + + def get_state(vm_id:str): + vm = Vm.get(vm_id) + state = vm.get_state() + return state + + def create_vm(vm_create: VmCreate): + vm = Vm(vm_create) + vm.create() + return vm + + def start_vm(vm_id: str): + vm = Vm.get(vm_id) + return vm.start() + + def stop_vm(vm_id: str): + vm = Vm.get(vm_id) + return vm.stop() + + def set_vm_password(vm_id: str): + vm = Vm.get(vm_id) + return vm.generate_password() + + diff --git a/backend/app/utils/vm.py b/backend/app/utils/vm.py new file mode 100644 index 0000000..34cf761 --- /dev/null +++ b/backend/app/utils/vm.py @@ -0,0 +1,8 @@ +from time import sleep +def wait_for_state(vm, state_code: int, timeout: float, retries: int,): + for _ in range(0, retries): + code, _ = vm.state() + if state_code == code: + return code + sleep(timeout) + return code diff --git a/backend/requirements.txt b/backend/requirements.txt index 13712cc..01700dc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1 +1,3 @@ fastapi[standard] +libvirt-python +lxml \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 446725a..caacd2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-poker_user} -d ${POSTGRES_NAME:-poker}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-distribox_user} -d ${POSTGRES_NAME:-distribox}"] interval: 10s timeout: 5s retries: 5 diff --git a/test/create-distribox-image/clear_vm.sh b/test/create-distribox-image/clear_vm.sh new file mode 100644 index 0000000..eb01491 --- /dev/null +++ b/test/create-distribox-image/clear_vm.sh @@ -0,0 +1,5 @@ +##on host machine +virt-sysprep -a /var/lib/distribox/images/os.qcow2 --operations bash-history,logfiles,tmp-files,crash-data,utmp +virt-sparsify --compress /var/lib/distribox/images/os.qcow2 /var/lib/distribox/images/distribox-ubuntu.qcow2 +rm -f /var/lib/distribox/images/os.qcow2 +rm -f /var/lib/distribox/images/seed.iso \ No newline at end of file diff --git a/test/create-distribox-image/create-distribox-image.sh b/test/create-distribox-image/create-distribox-image.sh new file mode 100644 index 0000000..5be3845 --- /dev/null +++ b/test/create-distribox-image/create-distribox-image.sh @@ -0,0 +1,27 @@ +VM_NAME=ubuntu-cloud-vm +CLOUD_IMG_URL=https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img +CLOUD_IMG_QCOW="os.qcow2" +LIBVIRT_IMG_PATH="/tmp/" +DISTRIBOX_IMG_PATH=/var/lib/distribox/images/ + +virsh destroy $VM_NAME +virsh undefine $VM_NAME --remove-all-storage +rm -f "${DISTRIBOX_IMG_PATH}${CLOUD_IMG_QCOW}" +rm -f "${DISTRIBOX_IMG_PATH}seed.iso" + +# download cloud image +wget -nc -P $LIBVIRT_IMG_PATH $CLOUD_IMG_URL + +cp "${LIBVIRT_IMG_PATH}${CLOUD_IMG_URL##*/}" "${DISTRIBOX_IMG_PATH}${CLOUD_IMG_QCOW}" + +qemu-img resize "${DISTRIBOX_IMG_PATH}${CLOUD_IMG_QCOW}" +5G + + +#generate configuration iso for cloud image, see what's in seed-config/ +genisoimage -output "${DISTRIBOX_IMG_PATH}seed.iso" -volid cidata -joliet -rock seed-config/ + +#defines and starts vm using vm.xml +virsh define vm.xml +virsh start $VM_NAME + +##run clear_vm.sh after the vm creation. It will shutdown when init is finished \ No newline at end of file diff --git a/test/create-distribox-image/seed-config/meta-data b/test/create-distribox-image/seed-config/meta-data new file mode 100644 index 0000000..52ca435 --- /dev/null +++ b/test/create-distribox-image/seed-config/meta-data @@ -0,0 +1,2 @@ +instance-id: ubuntu-cloud-vm +local-hostname: ubuntu-cloud-vm diff --git a/test/create-distribox-image/seed-config/user-data b/test/create-distribox-image/seed-config/user-data new file mode 100644 index 0000000..3b325b3 --- /dev/null +++ b/test/create-distribox-image/seed-config/user-data @@ -0,0 +1,39 @@ +#cloud-config + +# Nom d'hôte (peut aussi être mis dans meta-data, mais fonctionne ici) +hostname: distribox +fqdn: distribox.local + +# Configuration des utilisateurs +users: + - name: user + groups: sudo + shell: /bin/bash + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + +# Définition des mots de passe +chpasswd: + list: | + user:password + expire: false +ssh_pwauth: true + +package_update: true +package_upgrade: true +packages: + - qemu-guest-agent + - xubuntu-desktop + - vim +package_reboot_if_required: false + +runcmd: + - apt clean + - apt autoremove -y + - rm -rf /var/log/* + - rm -rf /tmp/* + - rm -rf /var/cache/apt/archives/* + - rm -rf /var/lib/systemd/coredump/* + - dd if=/dev/zero of=/EMPTY bs=1M || true + - rm -f /EMPTY + - sync + - shutdown -h now diff --git a/test/create-distribox-image/vm.xml b/test/create-distribox-image/vm.xml new file mode 100644 index 0000000..4b053b3 --- /dev/null +++ b/test/create-distribox-image/vm.xml @@ -0,0 +1,60 @@ + + ubuntu-cloud-vm + 2048 + 2 + + + hvm + + + + + + + + + + + + + + destroy + restart + destroy + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/libvirt-install.sh b/test/libvirt-install.sh new file mode 100644 index 0000000..da58ed3 --- /dev/null +++ b/test/libvirt-install.sh @@ -0,0 +1,16 @@ +sudo groupadd -f distribox +sudo usermod -aG distribox "$SUDO_USER" +sudo apt update +echo "Installing libvirt dependencies..." +sudo apt install -y qemu-kvm libvirt-daemon-system genisoimage libvirt-clients bridge-utils virtinst pkg-config libvirt-dev python3-dev +echo "Enabling libvirt daemon (libvirtd)..." +sudo systemctl enable --now libvirtd +sudo mkdir /var/lib/distribox/ +sudo mkdir /var/lib/distribox/images +sudo mkdir /var/lib/distribox/vms + +sudo chown -R root:distribox /var/lib/distribox + +sudo chmod 2775 /var/lib/distribox +sudo chmod 2775 /var/lib/distribox/images +sudo chmod 2775 /var/lib/distribox/vms