diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go
index fa8d4d4..46b3120 100644
--- a/backend/internal/api/images.go
+++ b/backend/internal/api/images.go
@@ -28,6 +28,7 @@ type ImageInfo struct {
Downloading bool `json:"downloading"`
SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"`
+ Desktop string `json:"desktop,omitempty"`
}
var imageDownloadsMu sync.Mutex
@@ -140,6 +141,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
Downloading: downloading,
SizeBytes: size,
ManualPath: manualPath,
+ Desktop: t.Desktop,
})
}
@@ -336,7 +338,7 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
- "description": t.Description, "type": config.VirtualizationKVM,
+ "description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
})
}
}
diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go
index 17c1769..403c4c9 100644
--- a/backend/internal/kvm/kvm.go
+++ b/backend/internal/kvm/kvm.go
@@ -371,13 +371,21 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
} else {
+ if image.Desktop != "" {
+ if cfg.RAMMB < 2048 {
+ cfg.RAMMB = 2048
+ }
+ if cfg.DiskGB < 20 {
+ cfg.DiskGB = 20
+ }
+ }
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err
}
- if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6); err != nil {
+ if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil {
return nil, err
}
- xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
+ xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
}
xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -706,7 +714,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
} else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
- xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
+ xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
}
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -731,7 +739,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
} else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
- xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
+ xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
}
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
return err
@@ -1668,8 +1676,11 @@ func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
-func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string) error {
+func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
guestSetup := kvmSSHSetupScript(password)
+ if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
+ guestSetup += "\n" + desktopSetup
+ }
if strings.TrimSpace(ipv6) != "" {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
}
@@ -1743,7 +1754,12 @@ func indentScript(script string, spaces int) string {
return strings.Join(lines, "\n")
}
-func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
+func isKVMDesktopTemplate(templateID string) bool {
+ image := FindImage(templateID)
+ return image != nil && image.Desktop != ""
+}
+
+func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int, desktop bool) string {
if vcpu < 1 {
vcpu = 1
}
@@ -1767,6 +1783,12 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
`, averageKiB, averageKiB)
}
+ video := ""
+ input := ""
+ if desktop {
+ video = ""
+ input = "\n\t "
+ }
return fmt.Sprintf(`
%s
%s
@@ -1810,10 +1832,10 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
-
-
+ %s
+ %s
-`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth)
+`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
}
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
@@ -2263,6 +2285,64 @@ fi
`
}
+func kvmDesktopSetupScript(image Image) string {
+ if strings.ToLower(strings.TrimSpace(image.Desktop)) != "xfce" {
+ return ""
+ }
+ packages := ""
+ switch image.Distro {
+ case "ubuntu":
+ packages = "xubuntu-desktop"
+ case "debian":
+ packages = "task-xfce-desktop"
+ default:
+ return ""
+ }
+ return `if command -v apt-get >/dev/null 2>&1; then
+ {
+ exec >>/var/log/clicd-desktop-setup.log 2>&1
+ echo "CLICD XFCE setup started at $(date -Is)"
+ export DEBIAN_FRONTEND=noninteractive
+ export APT_LISTCHANGES_FRONTEND=none
+ apt-get update || true
+ apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold ` + packages + ` || apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold xfce4 lightdm lightdm-gtk-greeter dbus-x11 xorg || true
+ if command -v useradd >/dev/null 2>&1 && ! id clicd >/dev/null 2>&1; then
+ useradd -m -s /bin/bash clicd || true
+ fi
+ if command -v chpasswd >/dev/null 2>&1 && id clicd >/dev/null 2>&1; then
+ printf 'clicd:%s\n' "$ROOT_PASSWORD" | chpasswd || true
+ fi
+ usermod -aG sudo clicd >/dev/null 2>&1 || true
+ usermod -aG autologin clicd >/dev/null 2>&1 || true
+ if id clicd >/dev/null 2>&1; then
+ printf 'startxfce4\n' >/home/clicd/.xsession || true
+ chown clicd:clicd /home/clicd/.xsession >/dev/null 2>&1 || true
+ fi
+ mkdir -p /etc/lightdm/lightdm.conf.d
+ cat >/etc/lightdm/lightdm.conf.d/50-clicd-autologin.conf <<'EOF'
+[Seat:*]
+autologin-user=clicd
+autologin-user-timeout=0
+user-session=xfce
+greeter-session=lightdm-gtk-greeter
+EOF
+ if [ -x /usr/sbin/lightdm ]; then
+ printf '/usr/sbin/lightdm\n' >/etc/X11/default-display-manager || true
+ fi
+ if command -v systemctl >/dev/null 2>&1; then
+ systemctl daemon-reload >/dev/null 2>&1 || true
+ systemctl set-default graphical.target >/dev/null 2>&1 || true
+ systemctl enable display-manager.service >/dev/null 2>&1 || true
+ systemctl enable lightdm.service >/dev/null 2>&1 || true
+ systemctl restart lightdm.service >/dev/null 2>&1 || systemctl start lightdm.service >/dev/null 2>&1 || true
+ fi
+ apt-get clean || true
+ echo "CLICD XFCE setup finished at $(date -Is)"
+ } || true
+fi
+`
+}
+
func qemuGuestPing(name string) error {
out, err := exec.Command("virsh", "qemu-agent-command", name, `{"execute":"guest-ping"}`).CombinedOutput()
if err != nil {
diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go
index a7f7e04..04f971e 100644
--- a/backend/internal/kvm/templates.go
+++ b/backend/internal/kvm/templates.go
@@ -12,6 +12,7 @@ type Image struct {
Arch string `json:"arch"`
Description string `json:"description"`
URL string `json:"url"`
+ Desktop string `json:"desktop,omitempty"`
}
func GetImages() []Image {
@@ -22,6 +23,13 @@ func GetImages() []Image {
Description: "Ubuntu 24.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
},
+ {
+ ID: "kvm-ubuntu-noble-xfce", Name: "Ubuntu 24.04 XFCE KVM",
+ Distro: "ubuntu", Release: "noble", Arch: "amd64",
+ Description: "Ubuntu 24.04 LTS cloud image with XFCE desktop provisioned via cloud-init",
+ URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
+ Desktop: "xfce",
+ },
{
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
@@ -34,6 +42,13 @@ func GetImages() []Image {
Description: "Debian 12 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
},
+ {
+ ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
+ Distro: "debian", Release: "bookworm", Arch: "amd64",
+ Description: "Debian 12 generic cloud image with XFCE desktop provisioned via cloud-init",
+ URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
+ Desktop: "xfce",
+ },
{
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "amd64",
diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep
index 30259b2..e69de29 100644
--- a/backend/internal/server/web/.gitkeep
+++ b/backend/internal/server/web/.gitkeep
@@ -1 +0,0 @@
-
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 1e457fe..dd6ad3a 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -94,6 +94,7 @@ export interface Template {
release: string
arch: string
variant?: string
+ desktop?: string
description: string
}
@@ -359,6 +360,7 @@ export interface ImageInfo {
downloading: boolean
size_bytes: number
manual_path?: string
+ desktop?: string
}
export const getImages = () =>