Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ class VMDefaultSettings(Base):
cloud_init = Column(Text, nullable=True)


class ContainerDefaultSettings(Base):
__tablename__ = "container_default_settings"

id = Column(Integer, primary_key=True, index=True)
username = Column(String, default="root")
cpu = Column(Integer, default=2)
memory = Column(Integer, default=2)
disk = Column(Integer, default=10)
image_fingerprint = Column(String, nullable=True) # LXD image fingerprint
image_alias = Column(String, nullable=True) # Image alias (e.g., "ubuntu/24.04")
image_description = Column(String, nullable=True) # Human-readable description
cloud_init = Column(Text, nullable=True)


class ConnectionTemplate(Base):
__tablename__ = "connection_templates"

Expand Down
15 changes: 14 additions & 1 deletion routes/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Dashboard routes"""
from datetime import datetime
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -114,3 +114,16 @@ async def dashboard(
"disk_total": metrics["disk_total"],
"disk_percent": metrics["disk_percent"]
})


@router.get("/api/vm-support")
async def check_vm_support():
"""
Check if the system supports LXD virtual machines.

Returns virtualization support status for UI to enable/disable VM options.
"""
from utils.virtualization import check_lxd_vm_support

result = check_lxd_vm_support()
return JSONResponse(result)
97 changes: 68 additions & 29 deletions routes/instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,31 @@ async def create_instance(
"message": "LXD not configured. Please configure LXD in Settings first."
})

# Get VM default settings for cloud-init
vm_settings = db.query(VMDefaultSettings).first()
cloud_init_template = vm_settings.cloud_init if vm_settings and vm_settings.cloud_init else None
vm_swap = vm_settings.swap if vm_settings and vm_settings.swap else 2
vm_username = vm_settings.username if vm_settings and vm_settings.username else "ubuntu"
image_fingerprint = vm_settings.image_fingerprint if vm_settings and vm_settings.image_fingerprint else None

# Check if a system user with the instance name already exists (before creating instance)
# This prevents conflicts with existing system users
from services.jump_user_service import jump_user_exists
if jump_user_exists(name):
return JSONResponse({
"success": False,
"message": f"A system user '{name}' already exists. Please choose a different instance name."
})

# Get VM or container default settings based on instance type
if instance_type == "container":
from core.models import ContainerDefaultSettings
instance_settings = db.query(ContainerDefaultSettings).first()
cloud_init_template = instance_settings.cloud_init if instance_settings and instance_settings.cloud_init else None
vm_swap = 2 # Not used for containers
vm_username = instance_settings.username if instance_settings else "root"
image_fingerprint = instance_settings.image_fingerprint if instance_settings else None
else:
from core.models import VMDefaultSettings
instance_settings = db.query(VMDefaultSettings).first()
cloud_init_template = instance_settings.cloud_init if instance_settings and instance_settings.cloud_init else None
vm_swap = instance_settings.swap if instance_settings and instance_settings.swap else 2
vm_username = instance_settings.username if instance_settings and instance_settings.username else "ubuntu"
image_fingerprint = instance_settings.image_fingerprint if instance_settings and instance_settings.image_fingerprint else None

# Pass the raw template (with placeholders) to the background task
# The background task will generate SSH keys and process the template
cloud_init = cloud_init_template
Expand Down Expand Up @@ -301,12 +319,31 @@ async def bulk_create_instances(
"message": "LXD not configured. Please configure LXD in Settings first."
})

# Get VM default settings
vm_settings = db.query(VMDefaultSettings).first()
cloud_init = vm_settings.cloud_init if vm_settings else None
vm_swap = vm_settings.swap if vm_settings else 2
vm_username = vm_settings.username if vm_settings else "ubuntu"
image_fingerprint = vm_settings.image_fingerprint if vm_settings else None
# Check if any system user with the instance names already exists (before creating instances)
# This prevents conflicts with existing system users
from services.jump_user_service import jump_user_exists
for name in instance_names:
if jump_user_exists(name):
return JSONResponse({
"success": False,
"message": f"A system user '{name}' already exists. Please choose different instance names."
})

# Get VM or container default settings based on instance type
if instance_type == "container":
from core.models import ContainerDefaultSettings
instance_settings = db.query(ContainerDefaultSettings).first()
cloud_init = instance_settings.cloud_init if instance_settings and instance_settings.cloud_init else None
vm_swap = 2 # Not used for containers
vm_username = instance_settings.username if instance_settings else "root"
image_fingerprint = instance_settings.image_fingerprint if instance_settings else None
else:
from core.models import VMDefaultSettings
instance_settings = db.query(VMDefaultSettings).first()
cloud_init = instance_settings.cloud_init if instance_settings and instance_settings.cloud_init else None
vm_swap = instance_settings.swap if instance_settings and instance_settings.swap else 2
vm_username = instance_settings.username if instance_settings else "ubuntu"
image_fingerprint = instance_settings.image_fingerprint if instance_settings else None

lxd_settings = {
"use_socket": lxd_settings_db.use_socket,
Expand Down Expand Up @@ -584,17 +621,13 @@ async def delete_instance(
):
"""Delete an LXD instance and its SSH keys"""
from services.jump_user_service import delete_jump_user

lxd_service = LXDService(db)
lxd_service.get_client()

if not lxd_service.is_connected():
return JSONResponse({"success": False, "message": "LXD not configured"})

# Get VM settings for username (to delete jump user)
vm_settings = db.query(VMDefaultSettings).first()
username = vm_settings.username if vm_settings else "ubuntu"

# Delete the LXD instance
result = lxd_service.delete_instance(instance_name)

Expand All @@ -612,9 +645,9 @@ async def delete_instance(
except ValueError:
result["message"] = "Instance deleted, invalid instance name format"

# Delete jump user from host system
# Delete jump user from host system (using instance name as the jump user)
try:
delete_jump_user(username)
delete_jump_user(instance_name)
except Exception:
pass # Don't fail deletion if jump user cleanup fails

Expand Down Expand Up @@ -670,22 +703,28 @@ async def download_ssh_config(
"message": "SSH keys not found for this instance"
})

# Get VM settings for username
# Create/update jump user on host system using instance name as username
# This ensures each instance has its own unique jump user
jump_user_result = create_jump_user(
username=instance_name, # Fallback (not used when instance_name provided)
public_key=keys["public_key"],
instance_name=instance_name # Use instance name as the jump user
)
if not jump_user_result.get("success"):
return JSONResponse({
"success": False,
"message": f"Failed to setup jump user: {jump_user_result.get('message')}"
})

# Get instance settings for username (used for VM login, not jump user)
instance_type = "virtual-machine" # Default, could be fetched from LXD if needed
vm_settings = db.query(VMDefaultSettings).first()
username = vm_settings.username if vm_settings else "ubuntu"

# Get connection templates from DB
templates = db.query(ConnectionTemplate).first()
ssh_template = templates.ssh_config_template if templates and templates.ssh_config_template else DEFAULT_SSH_CONFIG_TEMPLATE

# Create/update jump user on host system
jump_user_result = create_jump_user(username, keys["public_key"])
if not jump_user_result.get("success"):
return JSONResponse({
"success": False,
"message": f"Failed to setup jump user: {jump_user_result.get('message')}"
})

# Get VM IP address
from services.lxd_service import LXDService
lxd_service = LXDService(db)
Expand Down
108 changes: 92 additions & 16 deletions routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from sqlalchemy.orm import Session

from core.database import get_db
from core.models import AdminUser, LXDSettings, VMDefaultSettings
from core.models import AdminUser, LXDSettings, VMDefaultSettings, ContainerDefaultSettings
from core.config import settings
from core.security import get_password_hash, verify_password
from services.lxd_service import LXDService
Expand Down Expand Up @@ -58,27 +58,33 @@ async def settings_page(
password_error: str = None,
vm_success: str = None,
vm_error: str = None,
container_success: str = None,
container_error: str = None,
templates_success: str = None
):
"""Settings page - change password, LXD configuration, and VM defaults"""
"""Settings page - change password, LXD configuration, and VM/container defaults"""
from core.models import ConnectionTemplate

lxd_settings = db.query(LXDSettings).first()
vm_settings = db.query(VMDefaultSettings).first()
container_settings = db.query(ContainerDefaultSettings).first()
connection_templates = db.query(ConnectionTemplate).first()

return templates.TemplateResponse("admin/settings.html", {
"request": request,
"username": user.username,
"lxd_settings": lxd_settings,
"vm_settings": vm_settings,
"container_settings": container_settings,
"connection_templates": connection_templates,
"lxd_success": lxd_success,
"lxd_error": lxd_error,
"password_success": password_success,
"password_error": password_error,
"vm_success": vm_success,
"vm_error": vm_error,
"container_success": container_success,
"container_error": container_error,
"templates_success": templates_success
})

Expand Down Expand Up @@ -212,14 +218,14 @@ async def save_vm_settings(
"""Save default VM settings"""
# Validate username
from core.validators import validate_username, validate_positive_integer

is_valid, error = validate_username(username)
if not is_valid:
return RedirectResponse(
url=f"/settings?vm_error={error}",
status_code=303
)

# Validate cloud-init template if provided
if cloud_init.strip():
from services.cloud_init_service import validate_cloud_init_template
Expand Down Expand Up @@ -261,6 +267,69 @@ async def save_vm_settings(
return RedirectResponse(url="/settings?vm_success=VM defaults saved successfully", status_code=303)


@router.post("/settings/container")
async def save_container_settings(
request: Request,
username: str = Form(...),
cpu: int = Form(...),
memory: int = Form(...),
disk: int = Form(...),
image_fingerprint: str = Form(""),
image_alias: str = Form(""),
image_description: str = Form(""),
cloud_init: str = Form(""),
db: Session = Depends(get_db)
):
"""Save default container settings"""
# Validate username
from core.validators import validate_username

is_valid, error = validate_username(username)
if not is_valid:
return RedirectResponse(
url=f"/settings?container_error={error}",
status_code=303
)

# Validate cloud-init template if provided
if cloud_init.strip():
from services.cloud_init_service import validate_cloud_init_template
is_valid, error_msg = validate_cloud_init_template(cloud_init)
if not is_valid:
return RedirectResponse(
url=f"/settings?container_error={error_msg}",
status_code=303
)

settings = db.query(ContainerDefaultSettings).first()

if settings:
settings.username = username
settings.cpu = cpu
settings.memory = memory
settings.disk = disk
settings.image_fingerprint = image_fingerprint if image_fingerprint else None
settings.image_alias = image_alias if image_alias else None
settings.image_description = image_description if image_description else None
settings.cloud_init = cloud_init if cloud_init else None
else:
settings = ContainerDefaultSettings(
username=username,
cpu=cpu,
memory=memory,
disk=disk,
image_fingerprint=image_fingerprint if image_fingerprint else None,
image_alias=image_alias if image_alias else None,
image_description=image_description if image_description else None,
cloud_init=cloud_init if cloud_init else None
)
db.add(settings)

db.commit()

return RedirectResponse(url="/settings?container_success=Container defaults saved successfully", status_code=303)


@router.get("/settings/vm/template")
async def get_cloud_init_template():
"""Get the default cloud-init template"""
Expand All @@ -272,25 +341,32 @@ async def get_cloud_init_template():


@router.get("/settings/vm/images")
async def get_available_images(db: Session = Depends(get_db)):
"""Get available LXD images for VM creation"""
async def get_available_images(
db: Session = Depends(get_db),
instance_type: str = "virtual-machine"
):
"""Get available LXD images for VM or container creation"""
from services.lxd_service import LXDService

lxd_service = LXDService(db)
lxd_service.get_client()

if not lxd_service.is_connected():
return JSONResponse({
"success": False,
"message": "LXD not connected"
})

try:
images = []
for img in lxd_service.client.images.all():
# Filter by instance type
if img.type != instance_type:
continue

# Get image info
description = img.properties.get('description', 'Unknown')

# Handle aliases which can be dicts or objects
aliases = []
for a in img.aliases:
Expand All @@ -300,29 +376,29 @@ async def get_available_images(db: Session = Depends(get_db)):
name = getattr(a, 'name', None)
if name:
aliases.append(name)

# Handle created_at which might be datetime or string
created_at = None
if img.created_at:
if hasattr(img.created_at, 'isoformat'):
created_at = img.created_at.isoformat()
else:
created_at = str(img.created_at)

images.append({
"fingerprint": img.fingerprint[:12], # Short fingerprint
"full_fingerprint": img.fingerprint,
"description": f"{img.type}: {description}",
"description": f"{description}",
"aliases": aliases,
"architecture": img.architecture,
"type": img.type,
"size": img.size,
"created_at": created_at
})

# Sort by description
images.sort(key=lambda x: x['description'])

return JSONResponse({
"success": True,
"images": images
Expand Down
Loading
Loading