diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc9c77a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +name: CI + +on: + push: + pull_request: + +jobs: + frontend-format: + name: Check Frontend Format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: 'frontend/pnpm-lock.yaml' + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Install dependencies + working-directory: ./frontend + run: pnpm install --frozen-lockfile + + - name: Install prettier + working-directory: ./frontend + run: pnpm add -D prettier + + - name: Check formatting with prettier + working-directory: ./frontend + run: pnpm exec prettier --check app + + frontend-build: + name: Build Frontend + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: 'frontend/pnpm-lock.yaml' + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Install dependencies + working-directory: ./frontend + run: pnpm install --frozen-lockfile + + - name: Build frontend + working-directory: ./frontend + run: pnpm build + + backend-format: + name: Check Backend Format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install autopep8 + run: pip install autopep8 + + - name: Check formatting with autopep8 + working-directory: ./backend + run: | + autopep8 --diff --recursive --select=E,W app/ > /tmp/autopep8.diff || true + if [ -s /tmp/autopep8.diff ]; then + echo "Code formatting issues found. Please run: autopep8 --in-place --recursive --select=E,W app/" + cat /tmp/autopep8.diff + exit 1 + fi + # + # backend-typecheck: + # name: Check Backend Types and Build + # runs-on: ubuntu-latest + # steps: + # - name: Checkout code + # uses: actions/checkout@v4 + # + # - name: Setup Python + # uses: actions/setup-python@v5 + # with: + # python-version: '3.13' + # + # - name: Install dependencies + # working-directory: ./backend + # run: | + # pip install -r requirements.txt + # pip install mypy + # + # - name: Type check with mypy + # working-directory: ./backend + # run: | + # mypy app/ --ignore-missing-imports diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 511f86a..b85d37b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -15,15 +15,15 @@ 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: + try: qemu_conn = libvirt.open("qemu:///system") except libvirt.libvirtError: raise return qemu_conn - diff --git a/backend/app/core/xml_builder.py b/backend/app/core/xml_builder.py index 001a8d3..f1a896e 100644 --- a/backend/app/core/xml_builder.py +++ b/backend/app/core/xml_builder.py @@ -2,13 +2,15 @@ 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) + 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" @@ -30,12 +32,14 @@ def build_xml(vm_read: VmRead): 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, "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") + 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") @@ -47,11 +51,13 @@ def build_xml(vm_read: VmRead): 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, "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() + 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 3c9bd37..059f6fb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, HTTPException from fastapi.responses import JSONResponse -from app.routes import vm +from app.routes import vm app = FastAPI() @@ -11,6 +11,7 @@ async def global_exception_handler(_, exc: HTTPException): content={"detail": exc.detail} ) + @app.exception_handler(Exception) async def global_exception_handler(_, exc: Exception): return JSONResponse( diff --git a/backend/app/models/vm.py b/backend/app/models/vm.py index d907020..09d1662 100644 --- a/backend/app/models/vm.py +++ b/backend/app/models/vm.py @@ -1,15 +1,18 @@ 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 index 3a66890..75bd317 100644 --- a/backend/app/orm/vm.py +++ b/backend/app/orm/vm.py @@ -1,12 +1,13 @@ 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 + password: str | None = None diff --git a/backend/app/routes/vm.py b/backend/app/routes/vm.py index b69394d..0bb6ae0 100644 --- a/backend/app/routes/vm.py +++ b/backend/app/routes/vm.py @@ -1,39 +1,46 @@ -from fastapi import status, APIRouter +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): +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 + return VmService.set_vm_password(vm_id) diff --git a/backend/app/services/vm_service.py b/backend/app/services/vm_service.py index 28c34f8..8e855bf 100644 --- a/backend/app/services/vm_service.py +++ b/backend/app/services/vm_service.py @@ -12,6 +12,7 @@ from app.orm.vm import VmORM from fastapi import status, HTTPException + class Vm: def __init__(self, vm_create: VmCreate): self.id = uuid.uuid4() @@ -20,10 +21,9 @@ def __init__(self, vm_create: VmCreate): self.vcpus = vm_create.vcpus self.disk_size = vm_create.disk_size self.state: Optional[str] = None - def create(self): - if self.state : + if self.state: return self self.state = 'Stopped' vm_dir = VMS_DIR / str(self.id) @@ -36,11 +36,11 @@ def create(self): 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, + id=self.id, + os=self.os, + mem=self.mem, + vcpus=self.vcpus, + disk_size=self.disk_size, ) session.add(vm_record) session.commit() @@ -50,7 +50,7 @@ def create(self): @classmethod def get(cls, vm_id: str): - try: + try: conn = QEMUConfig.get_connection() vm = conn.lookupByName(vm_id) vm_state, _ = vm.state() @@ -61,15 +61,16 @@ def get(cls, vm_id: str): 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.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') + 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: @@ -91,13 +92,14 @@ def start(self): 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') + 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') + self.state = VM_STATE_NAMES.get(state_code, 'None') return self - + def stop(self): try: conn = QEMUConfig.get_connection() @@ -106,22 +108,23 @@ def stop(self): 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') + 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') + 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')} - + raise + return {"state": VM_STATE_NAMES.get(state, 'None')} + def generate_password(self): try: random_uuid = str(uuid.uuid4()) @@ -130,24 +133,26 @@ def generate_password(self): hasher.update(uuid_bytes) password = hasher.hexdigest() with Session(engine) as session: - statement = update(VmORM).where(VmORM.id == self.id).values(password=password) + 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): + def get_state(vm_id: str): vm = Vm.get(vm_id) state = vm.get_state() return state @@ -164,9 +169,7 @@ def start_vm(vm_id: str): 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 index 34cf761..c430d46 100644 --- a/backend/app/utils/vm.py +++ b/backend/app/utils/vm.py @@ -1,5 +1,7 @@ from time import sleep -def wait_for_state(vm, state_code: int, timeout: float, retries: int,): + + +def wait_for_state(vm, state_code: int, timeout: float, retries: int): for _ in range(0, retries): code, _ = vm.state() if state_code == code: diff --git a/backend/requirements.txt b/backend/requirements.txt index 01700dc..79e511e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,5 @@ fastapi[standard] libvirt-python -lxml \ No newline at end of file +lxml +sqlmodel +psycopg2