Skip to content

Commit 1335c32

Browse files
author
Laurent
committed
Merge branch 'main' into 80-distribox-slave
2 parents bc49147 + 88044be commit 1335c32

File tree

5 files changed

+91
-12
lines changed

5 files changed

+91
-12
lines changed

backend/app/core/policies.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@
8181
"policy": "vms:delete",
8282
"description": "Allows the user to remove virtual machines.",
8383
},
84+
{
85+
"policy": "vms:screenshot",
86+
"description": "Allows the user to make virtual machine screenshots.",
87+
},
88+
{
89+
"policy": "vms:duplicate",
90+
"description": "Allows the user to duplicate a virtual machine.",
91+
},
8492
{
8593
"policy": "vms:credentials:revoke",
8694
"description": "Allows the user to revoke virtual machine credentials.",

backend/app/core/xml_builder.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from lxml import etree
2-
from app.models.vm import VmRead
2+
from app.models.vm import VmCreateXML
33
from app.core.constants import VMS_DIR
4+
from app.core.config import VIRT_TYPE
45

56
LAYOUT_TO_KEYMAP = {
67
"en-us-qwerty": "en-us",
@@ -31,8 +32,7 @@
3132
}
3233

3334

34-
def build_xml(vm_read: VmRead):
35-
from app.core.config import VIRT_TYPE
35+
def build_xml(vm_read: VmCreateXML):
3636

3737
domain = etree.Element("domain", type=VIRT_TYPE)
3838

backend/app/models/vm.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ class VmCreate(VmBase):
2828
slave_id: Optional[UUID] = None
2929

3030

31+
class VmCreateXML(VmBase):
32+
id: UUID
33+
34+
3135
class VmCredentialCreateRequest(BaseModel):
3236
name: str = Field(min_length=1)
3337
password: Optional[str] = None

backend/app/routes/vm.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414
router = APIRouter()
1515

1616

17-
@router.get(
18-
"/{vm_id}/screenshot",
19-
status_code=status.HTTP_200_OK,
20-
responses={
21-
200: {"content": {"image/jpeg": {}}, "description": "JPEG thumbnail of the VM screen"},
22-
},
23-
)
17+
@router.get("/{vm_id}/screenshot",
18+
status_code=status.HTTP_200_OK,
19+
dependencies=[Depends(require_policy("vms:screenshot"))],
20+
responses={200: {"content": {"image/jpeg": {}},
21+
"description": "JPEG thumbnail of the VM screen"},
22+
},
23+
)
2424
async def get_vm_screenshot(vm_id: str, token: str = Query(...)):
2525
"""Screenshot endpoint using query-param JWT auth (for <img src=> usage)."""
2626
payload = decode_access_token(token)
@@ -51,6 +51,17 @@ async def get_vm_screenshot(vm_id: str, token: str = Query(...)):
5151
)
5252

5353

54+
@router.post(
55+
"/{vm_id}/duplicate",
56+
status_code=status.HTTP_200_OK,
57+
response_model=VmRead,
58+
dependencies=[Depends(require_policy("vms:duplicate"))],
59+
responses={403: {"model": MissingPoliciesResponse}},
60+
)
61+
def duplicate_vm(vm_id: str):
62+
return VmService.duplicate_vm(vm_id)
63+
64+
5465
@router.delete(
5566
"/clean/{vm_id}",
5667
status_code=status.HTTP_204_NO_CONTENT,

backend/app/services/vm_service.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from app.utils.vm import wait_for_state
77
from typing import Optional
88
from app.core.constants import VMS_DIR, IMAGES_DIR, VM_STATE_NAMES
9-
from app.models.vm import VmCreate, VmRead, VmCredentialCreateRequest, RecoverableVm, RecoverableVmCreate
9+
from app.models.vm import VmCreate, VmRead, VmCredentialCreateRequest, RecoverableVm, RecoverableVmCreate, VmCreateXML
1010
from app.models.image import ImageRead
1111
from app.core.xml_builder import build_xml
1212
from app.core.config import QEMUConfig, engine
@@ -24,6 +24,7 @@
2424
from app.services.image_service import ImageService
2525
import yaml
2626
from pathlib import Path
27+
from sqlalchemy.orm import make_transient
2728

2829
logger = logging.getLogger(__name__)
2930

@@ -101,7 +102,14 @@ def __init__(self, vm_create: VmCreate):
101102
["qemu-img", "resize", vm_path, f"+{self.disk_size}G"],
102103
check=True,
103104
)
104-
vm_xml = build_xml(self)
105+
vm_xml = build_xml(VmCreateXML(
106+
id=self.id,
107+
os=self.os,
108+
name=self.name,
109+
mem=self.mem,
110+
vcpus=self.vcpus,
111+
disk_size=self.disk_size
112+
))
105113
conn = QEMUConfig.get_connection()
106114
conn.defineXML(vm_xml)
107115
with Session(engine) as session:
@@ -292,6 +300,7 @@ def _get_vm_or_404(session: Session, vm_id: str) -> VmORM:
292300
)
293301
return vm_record
294302

303+
295304
@staticmethod
296305
def _get_slave_for_vm(vm_id: str) -> Optional[SlaveORM]:
297306
"""Check if a VM is hosted on a slave node."""
@@ -301,6 +310,21 @@ def _get_slave_for_vm(vm_id: str) -> Optional[SlaveORM]:
301310
return session.get(SlaveORM, vm_record.slave_id)
302311
return None
303312

313+
314+
@staticmethod
315+
def _get_duplicate_name(session: Session, vm_name: str) -> str:
316+
base_name = f"Duplicate of {vm_name}"
317+
search_pattern = f"{base_name}%"
318+
319+
statement = select(
320+
func.count(
321+
VmORM.id)).where(
322+
VmORM.name.like(search_pattern))
323+
count = session.exec(statement).one()
324+
if count == 0:
325+
return base_name
326+
return f"{base_name}({count})"
327+
304328
def get_vm_list():
305329
with Session(engine) as session:
306330
vm_records = session.scalars(select(VmORM)).all()
@@ -678,3 +702,35 @@ def remove_all_recoverable_vms():
678702
rmtree(VMS_DIR / v.name)
679703
break
680704
return
705+
706+
@staticmethod
707+
def duplicate_vm(vm_id: str):
708+
with Session(engine) as session:
709+
vm_to_duplicate = VmService._get_vm_or_404(session, vm_id)
710+
duplicate_vm = VmORM(**vm_to_duplicate.model_dump())
711+
duplicate_vm.id = uuid.uuid4()
712+
duplicate_vm.name = VmService._get_duplicate_name(
713+
session, duplicate_vm.name)
714+
715+
src_path = VMS_DIR / vm_id / duplicate_vm.os
716+
dest_path = VMS_DIR / str(duplicate_vm.id)
717+
718+
dest_path.mkdir(parents=True, exist_ok=True)
719+
copy(src_path, dest_path / duplicate_vm.os)
720+
721+
vm_xml = build_xml(VmCreateXML(**duplicate_vm.model_dump()))
722+
723+
try:
724+
conn = QEMUConfig.get_connection()
725+
conn.defineXML(vm_xml)
726+
session.add(duplicate_vm)
727+
session.commit()
728+
except Exception as e:
729+
if dest_path.exists():
730+
rmtree(dest_path)
731+
raise HTTPException(
732+
status_code=500,
733+
detail=f"Failed to duplicate VM: {str(e)}"
734+
)
735+
736+
return Vm.get(str(duplicate_vm.id))

0 commit comments

Comments
 (0)