From 6dd7079e2338384e08cddd0d38001d07751b3a40 Mon Sep 17 00:00:00 2001
From: MengMengCode <227010654+MengMengCode@users.noreply.github.com>
Date: Sun, 7 Jun 2026 09:24:09 +0800
Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E6=94=AF=E6=8C=81KVM?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.claude/worktrees/agent-ae3871aebda20eb86 | 1 +
backend/internal/api/handlers.go | 27 +-
backend/internal/api/images.go | 112 +-
backend/internal/api/ipv6.go | 2 +-
backend/internal/api/runtime.go | 172 ++
backend/internal/api/snapshots.go | 8 +-
backend/internal/api/ssh.go | 83 +-
backend/internal/api/taskqueue.go | 27 +-
backend/internal/config/config.go | 63 +
backend/internal/kvm/kvm.go | 2135 +++++++++++++++++
backend/internal/kvm/templates.go | 79 +
backend/internal/lxc/expiry.go | 4 +-
backend/internal/lxc/lxc.go | 17 +
backend/internal/lxc/portmap.go | 35 +-
backend/internal/lxc/snapshot.go | 2 +-
backend/internal/server/web/.gitkeep | 1 +
backend/main.go | 16 +-
.../src/components/CreateContainerModal.tsx | 147 +-
frontend/src/pages/ContainerDetail.tsx | 7 +-
frontend/src/pages/Containers.tsx | 192 +-
frontend/src/pages/ImageManagement.tsx | 38 +-
frontend/src/services/api.ts | 10 +-
22 files changed, 2986 insertions(+), 192 deletions(-)
create mode 160000 .claude/worktrees/agent-ae3871aebda20eb86
create mode 100644 backend/internal/api/runtime.go
create mode 100644 backend/internal/kvm/kvm.go
create mode 100644 backend/internal/kvm/templates.go
diff --git a/.claude/worktrees/agent-ae3871aebda20eb86 b/.claude/worktrees/agent-ae3871aebda20eb86
new file mode 160000
index 0000000..422e48b
--- /dev/null
+++ b/.claude/worktrees/agent-ae3871aebda20eb86
@@ -0,0 +1 @@
+Subproject commit 422e48b524e3c18e24a82e1e93287c7e30e3b395
diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go
index 46b9707..dd48f44 100644
--- a/backend/internal/api/handlers.go
+++ b/backend/internal/api/handlers.go
@@ -105,10 +105,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
}
func listContainers(w http.ResponseWriter, r *http.Request) {
- containers, err := lxcManager.ListContainers()
- if err != nil {
- containers = config.AppConfig.Containers
- }
+ containers, _ := listByRuntime()
containers = filterContainersForRequest(r, containers)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
}
@@ -123,11 +120,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return
}
+ cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
if cfg.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
return
}
- if !isTemplateEnabledAndDownloaded(cfg.TemplateID) {
+ if !isImageEnabledAndDownloaded(cfg.TemplateID, cfg.Virtualization) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
@@ -150,7 +148,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
- if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
+ if err := validateRuntimeResourceRequest(cfg.Virtualization, cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -166,7 +164,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
}
}
- if err := lxcManager.CreateContainer(cfg); err != nil {
+ if err := createByRuntime(cfg); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -183,7 +181,7 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
}
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
- usage, err := lxcManager.GetResourceUsage(id)
+ usage, err := usageByRuntime(id)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -192,7 +190,7 @@ func getUsage(w http.ResponseWriter, r *http.Request, id int) {
}
func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
- info := lxcManager.GetTrafficInfo(id)
+ info := trafficByRuntime(id)
if info == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
@@ -281,7 +279,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
if req.RAMMB > 0 {
nextRAMMB = req.RAMMB
}
- if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil {
+ if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -294,7 +292,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
// Re-apply resource limits to running container
if c.Status == "running" {
- if err := lxcManager.ApplyContainerLimits(c); err != nil {
+ if err := applyLimitsByRuntime(c); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -354,10 +352,7 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
- containers, err := lxcManager.ListContainers()
- if err != nil {
- containers = config.AppConfig.Containers
- }
+ containers, _ := listByRuntime()
running := 0
stopped := 0
for _, c := range containers {
@@ -391,7 +386,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return
}
- newPassword, err := lxcManager.ResetSSHPassword(id)
+ newPassword, err := resetPasswordByRuntime(id)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go
index 6369ae7..05f53a4 100644
--- a/backend/internal/api/images.go
+++ b/backend/internal/api/images.go
@@ -10,6 +10,7 @@ import (
"sync"
"clicd/internal/config"
+ "clicd/internal/kvm"
"clicd/internal/lxc"
)
@@ -17,6 +18,7 @@ import (
type ImageInfo struct {
ID string `json:"id"`
Name string `json:"name"`
+ Type string `json:"type"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
@@ -78,6 +80,9 @@ func getEnabledImageSet() map[string]bool {
for _, t := range lxc.GetTemplates() {
set[t.ID] = true
}
+ for _, t := range kvm.GetImages() {
+ set[t.ID] = true
+ }
} else {
for _, id := range config.AppConfig.EnabledImages {
set[id] = true
@@ -93,16 +98,34 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
return
}
- templates := lxc.GetTemplates()
enabledSet := getEnabledImageSet()
- images := make([]ImageInfo, 0, len(templates))
+ templates := lxc.GetTemplates()
+ images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates {
_, downloading := imageDownloads[t.ID]
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
+ Type: config.VirtualizationLXC,
+ Distro: t.Distro,
+ Release: t.Release,
+ Arch: t.Arch,
+ Description: t.Description,
+ Downloaded: downloaded,
+ Enabled: enabledSet[t.ID],
+ Downloading: downloading,
+ SizeBytes: size,
+ })
+ }
+ for _, t := range kvm.GetImages() {
+ _, downloading := imageDownloads[t.ID]
+ downloaded, size := kvm.ImageDownloadedInfo(t.ID)
+ images = append(images, ImageInfo{
+ ID: t.ID,
+ Name: t.Name,
+ Type: config.VirtualizationKVM,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
@@ -134,7 +157,35 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
- jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
+ image := kvm.FindImage(req.TemplateID)
+ if image == nil {
+ jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
+ return
+ }
+ if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
+ ensureImageEnabled(image.ID)
+ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
+ return
+ }
+ imageDownloadsMu.Lock()
+ if imageDownloads[req.TemplateID] {
+ imageDownloadsMu.Unlock()
+ jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
+ return
+ }
+ imageDownloads[req.TemplateID] = true
+ imageDownloadsMu.Unlock()
+ defer func() {
+ imageDownloadsMu.Lock()
+ delete(imageDownloads, req.TemplateID)
+ imageDownloadsMu.Unlock()
+ }()
+ ensureImageEnabled(image.ID)
+ if err := kvm.DownloadImage(*image); err != nil {
+ jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Download failed: " + err.Error()})
+ return
+ }
+ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
return
}
@@ -206,6 +257,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
+ if image := kvm.FindImage(req.TemplateID); image != nil {
+ if err := kvm.DeleteImage(image.ID); err != nil {
+ jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
+ return
+ }
+ removeImageEnabled(image.ID)
+ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
+ return
+ }
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return
}
@@ -259,13 +319,27 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
return
}
- templates := lxc.GetTemplates()
+ runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet()
- result := make([]lxc.Template, 0)
- for _, t := range templates {
- if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
- result = append(result, t)
+ result := make([]map[string]string, 0)
+ if runtime == config.VirtualizationKVM {
+ for _, t := range kvm.GetImages() {
+ 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,
+ })
+ }
+ }
+ } else {
+ for _, t := range lxc.GetTemplates() {
+ if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
+ result = append(result, map[string]string{
+ "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
+ "variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
+ })
+ }
}
}
@@ -273,6 +347,20 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
}
func isTemplateEnabledAndDownloaded(templateID string) bool {
+ return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
+}
+
+func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
+ runtime = runtimeFromRequest(runtime)
+ if runtime == config.VirtualizationKVM {
+ image := kvm.FindImage(templateID)
+ if image == nil {
+ return false
+ }
+ enabledSet := getEnabledImageSet()
+ downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
+ return enabledSet[image.ID] && downloaded
+ }
tmpl := lxc.FindTemplate(templateID)
if tmpl == nil {
return false
@@ -288,6 +376,9 @@ func ensureImageEnabled(id string) {
for _, t := range lxc.GetTemplates() {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
+ for _, t := range kvm.GetImages() {
+ config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
+ }
config.SaveConfig()
return // Already contains all IDs including this one
}
@@ -313,6 +404,11 @@ func removeImageEnabled(id string) {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
}
+ for _, t := range kvm.GetImages() {
+ if t.ID != id {
+ config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
+ }
+ }
config.SaveConfig()
return
}
diff --git a/backend/internal/api/ipv6.go b/backend/internal/api/ipv6.go
index 594d408..9087299 100644
--- a/backend/internal/api/ipv6.go
+++ b/backend/internal/api/ipv6.go
@@ -12,7 +12,7 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
}
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
- c, err := lxcManager.AssignIPv6(id)
+ c, err := assignIPv6ByRuntime(id)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
diff --git a/backend/internal/api/runtime.go b/backend/internal/api/runtime.go
new file mode 100644
index 0000000..b662a4a
--- /dev/null
+++ b/backend/internal/api/runtime.go
@@ -0,0 +1,172 @@
+package api
+
+import (
+ "fmt"
+ "math"
+ "os"
+ "strings"
+
+ "clicd/internal/config"
+ "clicd/internal/kvm"
+ "clicd/internal/lxc"
+)
+
+var kvmManager = kvm.NewManager()
+
+func runtimeFromRequest(value string) string {
+ return config.NormalizeVirtualization(value)
+}
+
+func runtimeFromTemplateID(templateID string) string {
+ if kvm.FindImage(templateID) != nil {
+ return config.VirtualizationKVM
+ }
+ return config.VirtualizationLXC
+}
+
+func createByRuntime(cfg lxc.ContainerConfig) error {
+ cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
+ if cfg.Virtualization == config.VirtualizationKVM {
+ return kvmManager.CreateContainer(cfg)
+ }
+ return lxcManager.CreateContainer(cfg)
+}
+
+func startByRuntime(id int) error {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.StartContainer(id)
+ }
+ return lxcManager.StartContainer(id)
+}
+
+func stopByRuntime(id int) error {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.StopContainer(id)
+ }
+ return lxcManager.StopContainer(id)
+}
+
+func restartByRuntime(id int) error {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.RestartContainer(id)
+ }
+ return lxcManager.RestartContainer(id)
+}
+
+func destroyByRuntime(id int) error {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.DestroyContainer(id)
+ }
+ return lxcManager.DestroyContainer(id)
+}
+
+func reinstallByRuntime(id int, templateID string) error {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.ReinstallContainer(id, templateID)
+ }
+ return lxcManager.ReinstallContainer(id, templateID)
+}
+
+func resetPasswordByRuntime(id int) (string, error) {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.ResetSSHPassword(id)
+ }
+ return lxcManager.ResetSSHPassword(id)
+}
+
+func assignIPv6ByRuntime(id int) (*config.Container, error) {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.AssignIPv6(id)
+ }
+ return lxcManager.AssignIPv6(id)
+}
+
+func usageByRuntime(id int) (map[string]interface{}, error) {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.GetResourceUsage(id)
+ }
+ return lxcManager.GetResourceUsage(id)
+}
+
+func trafficByRuntime(id int) map[string]interface{} {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.GetTrafficInfo(id)
+ }
+ return lxcManager.GetTrafficInfo(id)
+}
+
+func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
+ }
+ return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
+}
+
+func deleteSnapshotByRuntime(snapshotID string) error {
+ snapshot := config.FindSnapshot(snapshotID)
+ if snapshot != nil {
+ if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
+ return kvmManager.DeleteSnapshot(snapshotID)
+ }
+ if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
+ return kvmManager.DeleteSnapshot(snapshotID)
+ }
+ }
+ return lxcManager.DeleteSnapshot(snapshotID)
+}
+
+func restoreSnapshotByRuntime(snapshotID string) error {
+ snapshot := config.FindSnapshot(snapshotID)
+ if snapshot != nil {
+ if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
+ return kvmManager.RestoreSnapshot(snapshotID)
+ }
+ if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
+ return kvmManager.RestoreSnapshot(snapshotID)
+ }
+ }
+ return lxcManager.RestoreSnapshot(snapshotID)
+}
+
+func setSnapshotScheduleByRuntime(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
+ c := config.FindContainer(id)
+ if c != nil && c.IsKVM() {
+ return kvmManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
+ }
+ return lxcManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
+}
+
+func applyLimitsByRuntime(c *config.Container) error {
+ if c != nil && c.IsKVM() {
+ return kvmManager.ApplyContainerLimits(c)
+ }
+ return lxcManager.ApplyContainerLimits(c)
+}
+
+func listByRuntime() ([]config.Container, error) {
+ containers, err := lxcManager.ListContainers()
+ if err != nil {
+ containers = config.AppConfig.Containers
+ }
+ containers = kvmManager.ListContainers(containers)
+ return containers, err
+}
+
+func validateRuntimeResourceRequest(runtime string, vcpu float64, ramMB int, diskGB int) error {
+ if runtime == config.VirtualizationKVM {
+ if vcpu < 1 || math.Abs(vcpu-math.Round(vcpu)) > 0.000001 {
+ return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
+ }
+ }
+ return validateContainerResourceRequest(vcpu, ramMB, diskGB)
+}
diff --git a/backend/internal/api/snapshots.go b/backend/internal/api/snapshots.go
index e12fa70..4b6d69d 100644
--- a/backend/internal/api/snapshots.go
+++ b/backend/internal/api/snapshots.go
@@ -74,7 +74,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return
}
}
- snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0)
+ snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -138,7 +138,7 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
req.Time = "03:00"
}
user := requestUser(r)
- c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user)
+ c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
@@ -162,7 +162,7 @@ func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return
}
user := requestUser(r)
- if err := lxcManager.DeleteSnapshot(snapshotID); err != nil {
+ if err := deleteSnapshotByRuntime(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -177,7 +177,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
return
}
user := requestUser(r)
- if err := lxcManager.RestoreSnapshot(snapshotID); err != nil {
+ if err := restoreSnapshotByRuntime(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
diff --git a/backend/internal/api/ssh.go b/backend/internal/api/ssh.go
index 33f4591..d439369 100644
--- a/backend/internal/api/ssh.go
+++ b/backend/internal/api/ssh.go
@@ -101,16 +101,30 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
return
}
if c.IP == "" {
- if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil {
+ var ip string
+ var err error
+ if c.IsKVM() {
+ ip, err = kvmManager.GetContainerIP(c.VirshName())
+ } else {
+ ip, err = lxcManager.GetContainerIP(c.LxcName())
+ }
+ if err == nil {
c.IP = ip
config.SaveConfig()
}
}
- if c.IP == "" {
+ if c.IP == "" && !c.IsKVM() {
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
c.IP = ip
}
}
+ if c.IP == "" && c.IsKVM() {
+ if err := kvmManager.EnsureSSH(c.ID); err == nil {
+ if refreshed := config.FindContainer(c.ID); refreshed != nil {
+ c = refreshed
+ }
+ }
+ }
if c.IP == "" {
http.Error(w, "container ip is not available", http.StatusBadRequest)
return
@@ -127,6 +141,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
defer ws.Close()
if c.SSHPassword == "" {
+ if c.IsKVM() {
+ writeWebSocketText(ws, nil, "\r\nKVM SSH password is not available. Reinstall or reset after SSH is ready.\r\n")
+ return
+ }
writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n")
if err := lxcManager.EnsureSSH(c.ID); err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
@@ -154,25 +172,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
client, err := ssh.Dial("tcp", addr, sshConfig)
if err != nil {
- writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
- if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
- writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
- return
- }
- if refreshed := config.FindContainer(c.ID); refreshed != nil {
- c = refreshed
- }
- if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
- c.IP = ip
- config.SaveConfig()
- addr = net.JoinHostPort(c.IP, "22")
- }
- sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
- sshConfig.Timeout = 10 * time.Second
- client, err = ssh.Dial("tcp", addr, sshConfig)
- if err != nil {
- writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
- return
+ if c.IsKVM() {
+ writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n")
+ if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil {
+ writeWebSocketText(ws, nil, fmt.Sprintf("\r\nKVM SSH auto setup failed: %v\r\n", setupErr))
+ return
+ }
+ if refreshed := config.FindContainer(c.ID); refreshed != nil {
+ c = refreshed
+ }
+ if ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); ipErr == nil && ip != "" {
+ c.IP = ip
+ config.SaveConfig()
+ addr = net.JoinHostPort(c.IP, "22")
+ }
+ sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
+ sshConfig.Timeout = 10 * time.Second
+ client, err = ssh.Dial("tcp", addr, sshConfig)
+ if err != nil {
+ writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
+ return
+ }
+ } else {
+ writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
+ if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
+ writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
+ return
+ }
+ if refreshed := config.FindContainer(c.ID); refreshed != nil {
+ c = refreshed
+ }
+ if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
+ c.IP = ip
+ config.SaveConfig()
+ addr = net.JoinHostPort(c.IP, "22")
+ }
+ sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
+ sshConfig.Timeout = 10 * time.Second
+ client, err = ssh.Dial("tcp", addr, sshConfig)
+ if err != nil {
+ writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
+ return
+ }
}
}
defer client.Close()
diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go
index d460cc8..87e3751 100644
--- a/backend/internal/api/taskqueue.go
+++ b/backend/internal/api/taskqueue.go
@@ -226,7 +226,7 @@ func (q *TaskQueue) createWorker() {
c := config.FindContainerByName(task.Config.Name)
if c == nil {
// 1) Download image + apply limits (lxc-create)
- err := lxcManager.CreateContainer(task.Config)
+ err := createByRuntime(task.Config)
if err != nil {
task.Status = "failed"
task.Error = err.Error()
@@ -256,10 +256,10 @@ func (q *TaskQueue) createWorker() {
// 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains.
- startErr := lxcManager.StartContainer(c.ID)
+ startErr := startByRuntime(c.ID)
if startErr != nil {
if createdByTask {
- lxcManager.DestroyContainer(c.ID)
+ _ = destroyByRuntime(c.ID)
}
task.Status = "failed"
task.Error = startErr.Error()
@@ -304,13 +304,13 @@ func (q *TaskQueue) opWorker() {
if err == nil {
switch task.Type {
case TaskStart:
- err = lxcManager.StartContainer(task.ContainerID)
+ err = startByRuntime(task.ContainerID)
case TaskStop:
- err = lxcManager.StopContainer(task.ContainerID)
+ err = stopByRuntime(task.ContainerID)
case TaskRestart:
- err = lxcManager.RestartContainer(task.ContainerID)
+ err = restartByRuntime(task.ContainerID)
case TaskDelete:
- err = lxcManager.DestroyContainer(task.ContainerID)
+ err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
@@ -318,7 +318,7 @@ func (q *TaskQueue) opWorker() {
}
}
case TaskReinstall:
- err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID)
+ err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
@@ -456,7 +456,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
templateID = c.Template
}
}
- if !isTemplateEnabledAndDownloaded(templateID) {
+ runtime := runtimeFromTemplateID(templateID)
+ if c := config.FindContainer(id); c != nil {
+ runtime = c.Runtime()
+ }
+ if !isImageEnabledAndDownloaded(templateID, runtime) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
@@ -516,13 +520,14 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1
}
+ req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512
}
if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5
}
- if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) {
+ if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return
}
@@ -532,7 +537,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
}
- if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
+ if err := validateRuntimeResourceRequest(req.Containers[i].Virtualization, req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 8753389..34f6d8d 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -73,7 +73,11 @@ type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
+ Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,omitempty"`
+ KVMName string `json:"kvm_name,omitempty"`
+ DiskImage string `json:"disk_image,omitempty"`
+ MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
@@ -109,6 +113,28 @@ type Container struct {
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
}
+const (
+ VirtualizationLXC = "lxc"
+ VirtualizationKVM = "kvm"
+)
+
+func NormalizeVirtualization(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case VirtualizationKVM:
+ return VirtualizationKVM
+ default:
+ return VirtualizationLXC
+ }
+}
+
+func (c *Container) Runtime() string {
+ return NormalizeVirtualization(c.Virtualization)
+}
+
+func (c *Container) IsKVM() bool {
+ return c.Runtime() == VirtualizationKVM
+}
+
// LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string {
if c.LXCName != "" {
@@ -117,6 +143,14 @@ func (c *Container) LxcName() string {
return fmt.Sprintf("ct-%d", c.ID)
}
+// VirshName returns the internal libvirt domain name for KVM instances.
+func (c *Container) VirshName() string {
+ if c.KVMName != "" {
+ return c.KVMName
+ }
+ return fmt.Sprintf("vm-%d", c.ID)
+}
+
// SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct {
ID string `json:"id"`
@@ -343,6 +377,9 @@ func InitConfig() (*ClicdConfig, error) {
AppConfig.Oversell.SubUserSnapshotLimit = 3
}
changed := ensureContainerUUIDs()
+ if ensureContainerVirtualization() {
+ changed = true
+ }
if ensureContainerPortMappingLimits() {
changed = true
}
@@ -367,6 +404,18 @@ func InitConfig() (*ClicdConfig, error) {
return AppConfig, nil
}
+func ensureContainerVirtualization() bool {
+ changed := false
+ for i := range AppConfig.Containers {
+ next := NormalizeVirtualization(AppConfig.Containers[i].Virtualization)
+ if AppConfig.Containers[i].Virtualization != next {
+ AppConfig.Containers[i].Virtualization = next
+ changed = true
+ }
+ }
+ return changed
+}
+
func ensureContainerSnapshotScheduleDefaults() bool {
changed := false
for i := range AppConfig.Containers {
@@ -519,6 +568,7 @@ func AddContainer(c Container) {
if c.UUID == "" {
c.UUID = NewContainerUUID()
}
+ c.Virtualization = NormalizeVirtualization(c.Virtualization)
AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig()
}
@@ -792,6 +842,19 @@ func CleanStaleContainers() {
valid := make([]Container, 0)
changed := false
for _, c := range AppConfig.Containers {
+ if c.IsKVM() {
+ if c.DiskImage == "" {
+ valid = append(valid, c)
+ continue
+ }
+ if _, err := os.Stat(c.DiskImage); os.IsNotExist(err) {
+ fmt.Printf("Cleaning stale KVM config: %s (disk image not found)\n", c.VirshName())
+ changed = true
+ continue
+ }
+ valid = append(valid, c)
+ continue
+ }
lxcDir := "/var/lib/lxc/" + c.LxcName()
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go
new file mode 100644
index 0000000..c2f9f63
--- /dev/null
+++ b/backend/internal/kvm/kvm.go
@@ -0,0 +1,2135 @@
+package kvm
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "net/netip"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "clicd/internal/config"
+ "clicd/internal/lxc"
+
+ "golang.org/x/crypto/ssh"
+)
+
+type Manager struct {
+ BasePath string
+}
+
+const ipv6GatewayLinkLocal = "fe80::1"
+
+type usageSample struct {
+ CPUUsec uint64
+ RXBytes uint64
+ TXBytes uint64
+ ReadBytes uint64
+ WriteBytes uint64
+ At time.Time
+}
+
+type rateSnapshot struct {
+ CPUPct float64
+ RXBps float64
+ TXBps float64
+ ReadBps float64
+ WriteBps float64
+ UpdatedAt time.Time
+}
+
+type trafficSample struct {
+ RXBytes uint64
+ TXBytes uint64
+}
+
+var (
+ usageMu sync.RWMutex
+ lastUsage = map[string]usageSample{}
+ rateCache = map[string]rateSnapshot{}
+ trafficMu sync.Mutex
+ lastTrafficSnapshot = map[string]trafficSample{}
+ kvmSnapshotMu sync.Mutex
+ kvmSSHEnsureLocks sync.Map
+)
+
+func BaseDir() string {
+ return "/var/lib/clicd/kvm"
+}
+
+func NewManager() *Manager {
+ return &Manager{BasePath: BaseDir()}
+}
+
+func (m *Manager) instancesDir() string {
+ return filepath.Join(m.BasePath, "instances")
+}
+
+func (m *Manager) instanceDir(name string) string {
+ return filepath.Join(m.instancesDir(), name)
+}
+
+func ImageDownloadedInfo(id string) (bool, int64) {
+ path := ImagePath(id)
+ info, err := os.Stat(path)
+ if err != nil || info.IsDir() {
+ return false, 0
+ }
+ return true, info.Size()
+}
+
+func DownloadImage(image Image) error {
+ if err := os.MkdirAll(CacheDir(), 0755); err != nil {
+ return err
+ }
+ target := ImagePath(image.ID)
+ if ok, _ := ImageDownloadedInfo(image.ID); ok {
+ return nil
+ }
+ tmp := target + ".tmp"
+ _ = os.Remove(tmp)
+ if err := downloadFile(image.URL, tmp); err != nil {
+ _ = os.Remove(tmp)
+ return err
+ }
+ if err := normalizeQCOW2(tmp, target); err != nil {
+ _ = os.Remove(tmp)
+ return err
+ }
+ _ = os.Chmod(target, 0644)
+ return nil
+}
+
+func DeleteImage(id string) error {
+ return os.RemoveAll(ImagePath(id))
+}
+
+func downloadFile(url, target string) error {
+ client := http.Client{Timeout: 30 * time.Minute}
+ resp, err := client.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("download failed: %s", resp.Status)
+ }
+ out, err := os.Create(target)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ if _, err := io.Copy(out, resp.Body); err != nil {
+ return err
+ }
+ return out.Sync()
+}
+
+func normalizeQCOW2(src, target string) error {
+ if err := requireCommand("qemu-img"); err != nil {
+ return err
+ }
+ cmd := exec.Command("qemu-img", "convert", "-O", "qcow2", src, target)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output))
+ }
+ return os.Remove(src)
+}
+
+func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
+ image := FindImage(cfg.TemplateID)
+ if image == nil {
+ return fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
+ }
+ if ok, _ := ImageDownloadedInfo(image.ID); !ok {
+ return fmt.Errorf("KVM image is not downloaded: %s", cfg.TemplateID)
+ }
+ if err := m.validateHost(); err != nil {
+ return err
+ }
+ if !config.IsValidContainerName(cfg.Name) {
+ return fmt.Errorf("invalid VM name: %s", cfg.Name)
+ }
+ if config.FindContainerByName(cfg.Name) != nil {
+ return fmt.Errorf("container name already exists: %s", cfg.Name)
+ }
+ if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
+ return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
+ }
+ if cfg.PortMappingCount < 2 {
+ cfg.PortMappingCount = 2
+ }
+ if cfg.SnapshotLimit <= 0 {
+ cfg.SnapshotLimit = config.DefaultSnapshotLimit
+ }
+
+ id := config.AllocateContainerID()
+ vmName := fmt.Sprintf("vm-%d", id)
+ c, err := m.defineContainer(id, vmName, cfg, true)
+ if err != nil {
+ _ = m.cleanupVM(vmName)
+ return err
+ }
+ config.AddContainer(*c)
+ return nil
+}
+
+func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig, allocatePorts bool) (*config.Container, error) {
+ image := FindImage(cfg.TemplateID)
+ if image == nil {
+ return nil, fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
+ }
+ if err := os.MkdirAll(m.instanceDir(vmName), 0700); err != nil {
+ return nil, err
+ }
+ if err := os.Chmod(m.BasePath, 0755); err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ if err := os.Chmod(m.instancesDir(), 0755); err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ if err := os.Chmod(m.instanceDir(vmName), 0755); err != nil {
+ return nil, err
+ }
+ diskPath := filepath.Join(m.instanceDir(vmName), "disk.qcow2")
+ seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
+ mac := randomMAC()
+ sshPassword := generateRandomString(16)
+ ipv6 := ""
+ ipv6PrefixLen := 0
+ ipv6Interface := ""
+ if cfg.AssignIPv6 {
+ assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
+ if err != nil {
+ return nil, err
+ }
+ ipv6 = assigned
+ ipv6PrefixLen = prefixLen
+ ipv6Interface = iface
+ }
+
+ 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 {
+ return nil, err
+ }
+
+ xml := domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
+ xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml")
+ if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
+ return nil, err
+ }
+ cmd := exec.Command("virsh", "define", xmlPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return nil, fmt.Errorf("virsh define failed: %v, output: %s", err, string(output))
+ }
+
+ sshPort := 0
+ portMappings := []config.PortMapping{}
+ if allocatePorts {
+ sshPort = config.AllocateSSHPort()
+ portMappings = lxc.SetupDefaultPortMappings(sshPort)
+ tempC := &config.Container{PortMappings: portMappings}
+ extraPorts := cfg.ExtraPorts
+ if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
+ extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
+ }
+ for _, port := range extraPorts {
+ if port <= 0 {
+ continue
+ }
+ tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
+ ContainerPort: port,
+ HostPort: port,
+ Protocol: "tcp",
+ Description: fmt.Sprintf("Port-%d", port),
+ })
+ }
+ portMappings = tempC.PortMappings
+ }
+
+ now := time.Now().Format("2006-01-02 15:04:05")
+ trafficMode := cfg.TrafficMode
+ if trafficMode == "" {
+ trafficMode = "total"
+ }
+ return &config.Container{
+ ID: id,
+ UUID: config.NewContainerUUID(),
+ Name: cfg.Name,
+ Virtualization: config.VirtualizationKVM,
+ KVMName: vmName,
+ DiskImage: diskPath,
+ MACAddress: mac,
+ Template: cfg.TemplateID,
+ VCPU: cfg.VCPU,
+ RAMMB: cfg.RAMMB,
+ DiskGB: cfg.DiskGB,
+ NetworkBWMbps: cfg.NetworkBWMbps,
+ MonthlyTrafficGB: cfg.MonthlyTrafficGB,
+ TrafficMode: trafficMode,
+ TrafficInGB: cfg.TrafficInGB,
+ TrafficOutGB: cfg.TrafficOutGB,
+ TrafficResetDate: now[:7],
+ IOSpeedMBps: cfg.IOSpeedMBps,
+ IPv6: ipv6,
+ IPv6PrefixLen: ipv6PrefixLen,
+ IPv6Interface: ipv6Interface,
+ Status: "stopped",
+ SSHPort: sshPort,
+ SSHPassword: sshPassword,
+ PortMappings: portMappings,
+ PortMappingLimit: cfg.PortMappingCount,
+ SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
+ CreatedAt: now,
+ ExpiresAt: cfg.ExpiresAt,
+ }, nil
+}
+
+func (m *Manager) StartContainer(id int) error {
+ c := config.FindContainer(id)
+ if c == nil {
+ return fmt.Errorf("container not found: %d", id)
+ }
+ if err := m.validateHost(); err != nil {
+ return err
+ }
+ name := c.VirshName()
+ if err := m.ensureDomainDefinition(c); err != nil {
+ fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err)
+ }
+ status, _ := m.GetContainerStatus(name)
+ if status != "running" {
+ cmd := exec.Command("virsh", "start", name)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("virsh start failed: %v, output: %s", err, string(output))
+ }
+ }
+ config.UpdateContainerStatus(id, "running")
+ _ = exec.Command("virsh", "dommemstat", name, "--period", "10", "--live").Run()
+ _ = exec.Command("virsh", "dommemstat", name, "--period", "10", "--config").Run()
+ for i := 0; i < 90; i++ {
+ if ip, err := m.GetContainerIP(name); err == nil && ip != "" {
+ c.IP = ip
+ config.SaveConfig()
+ break
+ }
+ time.Sleep(2 * time.Second)
+ }
+ if c.IP == "" {
+ return fmt.Errorf("KVM VM %s started but no IPv4 address was detected", c.Name)
+ }
+ if err := lxc.NewManager().ApplyPortMappings(id); err != nil {
+ return err
+ }
+ return m.EnsureSSH(id)
+}
+
+func (m *Manager) StopContainer(id int) error {
+ c := config.FindContainer(id)
+ if c == nil {
+ return fmt.Errorf("container not found: %d", id)
+ }
+ _ = lxc.NewManager().CleanPortMappings(id)
+ name := c.VirshName()
+ status, _ := m.GetContainerStatus(name)
+ if status != "running" {
+ config.UpdateContainerStatus(id, "stopped")
+ return nil
+ }
+ exec.Command("virsh", "shutdown", name).Run()
+ for i := 0; i < 20; i++ {
+ if status, _ := m.GetContainerStatus(name); status != "running" {
+ config.UpdateContainerStatus(id, "stopped")
+ return nil
+ }
+ time.Sleep(1 * time.Second)
+ }
+ cmd := exec.Command("virsh", "destroy", name)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("virsh destroy failed: %v, output: %s", err, string(output))
+ }
+ config.UpdateContainerStatus(id, "stopped")
+ return nil
+}
+
+func (m *Manager) RestartContainer(id int) error {
+ if err := m.StopContainer(id); err != nil {
+ return err
+ }
+ time.Sleep(1 * time.Second)
+ return m.StartContainer(id)
+}
+
+func (m *Manager) DestroyContainer(id int) error {
+ c := config.FindContainer(id)
+ if c == nil {
+ return fmt.Errorf("container not found: %d", id)
+ }
+ name := c.VirshName()
+ _ = m.StopContainer(id)
+ _ = undefineDomain(name)
+ if err := os.RemoveAll(m.instanceDir(name)); err != nil {
+ return err
+ }
+ if !config.RemoveContainer(id) {
+ return fmt.Errorf("VM destroyed but config entry was not removed: %d", id)
+ }
+ return nil
+}
+
+func (m *Manager) ReinstallContainer(id int, templateID string) error {
+ c := config.FindContainer(id)
+ if c == nil {
+ return fmt.Errorf("container not found: %d", id)
+ }
+ image := FindImage(templateID)
+ if image == nil {
+ return fmt.Errorf("KVM image not found: %s", templateID)
+ }
+ if ok, _ := ImageDownloadedInfo(image.ID); !ok {
+ return fmt.Errorf("KVM image is not downloaded: %s", templateID)
+ }
+ name := c.VirshName()
+ _ = m.StopContainer(id)
+ _ = undefineDomain(name)
+ _ = os.RemoveAll(m.instanceDir(name))
+ cfg := lxc.ContainerConfig{
+ Name: c.Name,
+ TemplateID: templateID,
+ VCPU: c.VCPU,
+ RAMMB: c.RAMMB,
+ DiskGB: c.DiskGB,
+ NetworkBWMbps: c.NetworkBWMbps,
+ MonthlyTrafficGB: c.MonthlyTrafficGB,
+ TrafficMode: c.TrafficMode,
+ TrafficInGB: c.TrafficInGB,
+ TrafficOutGB: c.TrafficOutGB,
+ IOSpeedMBps: c.IOSpeedMBps,
+ PortMappingCount: c.PortMappingLimit,
+ SnapshotLimit: c.SnapshotLimit,
+ ExpiresAt: c.ExpiresAt,
+ }
+ next, err := m.defineContainer(id, name, cfg, false)
+ if err != nil {
+ return err
+ }
+ c.Template = templateID
+ c.DiskImage = next.DiskImage
+ c.MACAddress = next.MACAddress
+ c.SSHPassword = next.SSHPassword
+ c.SSHHostKey = ""
+ c.IP = ""
+ c.Status = "stopped"
+ config.SaveConfig()
+ return m.StartContainer(id)
+}
+
+func (m *Manager) ResetSSHPassword(id int) (string, error) {
+ c := config.FindContainer(id)
+ if c == nil {
+ return "", fmt.Errorf("container not found: %d", id)
+ }
+ if c.Status != "running" || c.IP == "" || c.SSHPassword == "" {
+ return "", fmt.Errorf("KVM VM must be running with SSH ready before password reset")
+ }
+ if err := m.EnsureSSH(id); err != nil {
+ return "", err
+ }
+ password := generateRandomString(16)
+ client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
+ User: "root",
+ Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ Timeout: 8 * time.Second,
+ })
+ if err != nil {
+ return "", err
+ }
+ defer client.Close()
+ session, err := client.NewSession()
+ if err != nil {
+ return "", err
+ }
+ defer session.Close()
+ cmd := fmt.Sprintf("printf 'root:%s\\n' | chpasswd", shellQuote(password))
+ if output, err := session.CombinedOutput(cmd); err != nil {
+ return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output))
+ }
+ c.SSHPassword = password
+ c.SSHHostKey = ""
+ config.SaveConfig()
+ return password, nil
+}
+
+func (m *Manager) ApplyContainerLimits(c *config.Container) error {
+ if c == nil || !c.IsKVM() {
+ return nil
+ }
+ if c.Status == "running" {
+ return fmt.Errorf("KVM resource changes require shutdown and start")
+ }
+ if c.DiskImage == "" || c.MACAddress == "" {
+ return nil
+ }
+ 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)
+ xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
+ if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
+ return err
+ }
+ cmd := exec.Command("virsh", "define", xmlPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("virsh define failed: %v, output: %s", err, string(output))
+ }
+ return nil
+}
+
+func (m *Manager) ensureDomainDefinition(c *config.Container) error {
+ if c == nil || !c.IsKVM() || c.DiskImage == "" || c.MACAddress == "" {
+ return nil
+ }
+ 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)
+ xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
+ if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
+ return err
+ }
+ cmd := exec.Command("virsh", "define", xmlPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("virsh define failed: %v, output: %s", err, string(output))
+ }
+ return nil
+}
+
+func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
+ kvmSnapshotMu.Lock()
+ defer kvmSnapshotMu.Unlock()
+
+ c := config.FindContainer(id)
+ if c == nil {
+ return config.Snapshot{}, fmt.Errorf("container not found: %d", id)
+ }
+ if !c.IsKVM() {
+ return config.Snapshot{}, fmt.Errorf("container is not a KVM VM: %d", id)
+ }
+ if scheduled && rotateLimit > 0 {
+ for {
+ existing := config.ContainerSnapshots(id)
+ if len(existing) < rotateLimit {
+ break
+ }
+ sortSnapshotsOldestFirst(existing)
+ if err := m.deleteSnapshotLocked(existing[0]); err != nil {
+ return config.Snapshot{}, err
+ }
+ }
+ }
+
+ name := c.VirshName()
+ instanceDir := m.instanceDir(name)
+ if err := safePathUnder(instanceDir, m.instancesDir()); err != nil {
+ return config.Snapshot{}, err
+ }
+ if _, err := os.Stat(instanceDir); err != nil {
+ return config.Snapshot{}, fmt.Errorf("VM storage not found: %v", err)
+ }
+
+ now := time.Now()
+ snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
+ snapshotDir := filepath.Join(snapshotBaseDir(), "kvm", strconv.Itoa(id), snapshotID)
+ if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
+ return config.Snapshot{}, err
+ }
+ if err := os.MkdirAll(snapshotDir, 0700); err != nil {
+ return config.Snapshot{}, err
+ }
+
+ wasRunning, err := m.prepareVMForColdCopy(id, name)
+ if err != nil {
+ _ = os.RemoveAll(snapshotDir)
+ return config.Snapshot{}, err
+ }
+ if wasRunning {
+ defer func() {
+ if err := m.StartContainer(id); err != nil {
+ fmt.Printf("Warning: failed to restart %s after snapshot: %v\n", name, err)
+ }
+ }()
+ }
+
+ if err := copyTree(instanceDir, snapshotDir); err != nil {
+ _ = os.RemoveAll(snapshotDir)
+ return config.Snapshot{}, err
+ }
+
+ snapshot := config.Snapshot{
+ ID: snapshotID,
+ ContainerID: c.ID,
+ ContainerName: c.Name,
+ LXCName: name,
+ CreatedAt: now.Format("2006-01-02 15:04:05"),
+ CreatedBy: createdBy,
+ Scheduled: scheduled,
+ Path: snapshotDir,
+ SizeBytes: dirSizeBytes(snapshotDir),
+ }
+ config.AddSnapshot(snapshot)
+ return snapshot, nil
+}
+
+func (m *Manager) DeleteSnapshot(id string) error {
+ kvmSnapshotMu.Lock()
+ defer kvmSnapshotMu.Unlock()
+
+ snapshot := config.FindSnapshot(id)
+ if snapshot == nil {
+ return fmt.Errorf("snapshot not found: %s", id)
+ }
+ return m.deleteSnapshotLocked(*snapshot)
+}
+
+func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
+ if snapshot.Path != "" {
+ if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
+ return err
+ }
+ if err := os.RemoveAll(snapshot.Path); err != nil {
+ return fmt.Errorf("failed to delete snapshot files: %v", err)
+ }
+ }
+ config.RemoveSnapshot(snapshot.ID)
+ return nil
+}
+
+func (m *Manager) RestoreSnapshot(id string) error {
+ kvmSnapshotMu.Lock()
+ defer kvmSnapshotMu.Unlock()
+
+ snapshot := config.FindSnapshot(id)
+ if snapshot == nil {
+ return fmt.Errorf("snapshot not found: %s", id)
+ }
+ if snapshot.Path == "" {
+ return fmt.Errorf("snapshot path is empty")
+ }
+ if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
+ return err
+ }
+ if _, err := os.Stat(snapshot.Path); err != nil {
+ return fmt.Errorf("snapshot files not found: %v", err)
+ }
+
+ c := config.FindContainer(snapshot.ContainerID)
+ if c == nil {
+ return fmt.Errorf("container not found: %d", snapshot.ContainerID)
+ }
+ if !c.IsKVM() {
+ return fmt.Errorf("container is not a KVM VM: %d", c.ID)
+ }
+ name := c.VirshName()
+ instanceDir := m.instanceDir(name)
+ if err := safePathUnder(instanceDir, m.instancesDir()); err != nil {
+ return err
+ }
+
+ wasRunning, err := m.prepareVMForColdCopy(c.ID, name)
+ if err != nil {
+ return err
+ }
+ backupDir := filepath.Join(m.instancesDir(), fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
+ if err := safePathUnder(backupDir, m.instancesDir()); err != nil {
+ return err
+ }
+ if err := os.Rename(instanceDir, backupDir); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to move current VM aside: %v", err)
+ }
+ if err := copyTree(snapshot.Path, instanceDir); err != nil {
+ _ = os.RemoveAll(instanceDir)
+ _ = os.Rename(backupDir, instanceDir)
+ return fmt.Errorf("failed to restore snapshot: %v", err)
+ }
+ _ = os.RemoveAll(backupDir)
+ if err := undefineDomain(name); err != nil {
+ fmt.Printf("Warning: failed to undefine %s before restore redefine: %v\n", name, err)
+ }
+ xmlPath := filepath.Join(instanceDir, "domain.xml")
+ if output, err := exec.Command("virsh", "define", xmlPath).CombinedOutput(); err != nil {
+ return fmt.Errorf("virsh define failed after restore: %v, output: %s", err, string(output))
+ }
+ c.DiskImage = filepath.Join(instanceDir, "disk.qcow2")
+ c.Status = "stopped"
+ c.IP = ""
+ config.SaveConfig()
+ if wasRunning {
+ return m.StartContainer(c.ID)
+ }
+ return nil
+}
+
+func (m *Manager) SetSnapshotSchedule(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
+ c := config.FindContainer(id)
+ if c == nil {
+ return nil, fmt.Errorf("container not found: %d", id)
+ }
+ if intervalHours < 24 {
+ return nil, fmt.Errorf("snapshot schedule interval cannot be less than 24 hours")
+ }
+ if _, err := parseScheduleClock(scheduleTime); err != nil {
+ return nil, err
+ }
+ c.SnapshotScheduleEnabled = enabled
+ c.SnapshotScheduleIntervalHours = intervalHours
+ c.SnapshotScheduleTime = scheduleTime
+ c.SnapshotScheduleCreatedBy = createdBy
+ if enabled {
+ c.SnapshotScheduleNextRun = nextSnapshotRun(time.Now(), intervalHours, scheduleTime).Format(time.RFC3339)
+ } else {
+ c.SnapshotScheduleNextRun = ""
+ }
+ if err := config.SaveConfig(); err != nil {
+ return nil, err
+ }
+ return c, nil
+}
+
+func (m *Manager) StartSnapshotScheduler() {
+ go func() {
+ m.runDueSnapshotSchedules()
+ ticker := time.NewTicker(time.Minute)
+ defer ticker.Stop()
+ for range ticker.C {
+ m.runDueSnapshotSchedules()
+ }
+ }()
+}
+
+func (m *Manager) runDueSnapshotSchedules() {
+ now := time.Now()
+ containers := append([]config.Container(nil), config.AppConfig.Containers...)
+ for _, c := range containers {
+ if !c.IsKVM() || !c.SnapshotScheduleEnabled {
+ continue
+ }
+ nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
+ if err != nil || c.SnapshotScheduleNextRun == "" {
+ nextRun = now
+ }
+ if now.Before(nextRun) {
+ continue
+ }
+ createdBy := c.SnapshotScheduleCreatedBy
+ if createdBy == "" {
+ createdBy = "admin"
+ }
+ rotateLimit := 0
+ if strings.HasPrefix(createdBy, "user:") {
+ rotateLimit = config.ContainerSnapshotLimit(&c)
+ }
+ if _, err := m.CreateSnapshot(c.ID, createdBy, true, rotateLimit); err != nil {
+ fmt.Printf("Warning: scheduled KVM snapshot failed for %s: %v\n", c.Name, err)
+ continue
+ }
+ if current := config.FindContainer(c.ID); current != nil {
+ interval := current.SnapshotScheduleIntervalHours
+ if interval < 24 {
+ interval = 24
+ }
+ next := nextRun.Add(time.Duration(interval) * time.Hour)
+ for !next.After(now) {
+ next = next.Add(time.Duration(interval) * time.Hour)
+ }
+ current.SnapshotScheduleLastRun = now.Format(time.RFC3339)
+ current.SnapshotScheduleNextRun = next.Format(time.RFC3339)
+ config.SaveConfig()
+ }
+ }
+}
+
+func (m *Manager) prepareVMForColdCopy(id int, name string) (bool, error) {
+ status, _ := m.GetContainerStatus(name)
+ wasRunning := status == "running"
+ if wasRunning {
+ if err := m.StopContainer(id); err != nil {
+ return false, err
+ }
+ time.Sleep(time.Second)
+ } else {
+ _ = lxc.NewManager().CleanPortMappings(id)
+ }
+ return wasRunning, nil
+}
+
+func parseScheduleClock(value string) (time.Duration, error) {
+ parts := strings.Split(value, ":")
+ if len(parts) != 2 {
+ return 0, fmt.Errorf("snapshot schedule time must be HH:MM")
+ }
+ hour, err := strconv.Atoi(parts[0])
+ if err != nil || hour < 0 || hour > 23 {
+ return 0, fmt.Errorf("snapshot schedule hour must be 00-23")
+ }
+ minute, err := strconv.Atoi(parts[1])
+ if err != nil || minute < 0 || minute > 59 {
+ return 0, fmt.Errorf("snapshot schedule minute must be 00-59")
+ }
+ return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
+}
+
+func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) time.Time {
+ clock, err := parseScheduleClock(scheduleTime)
+ if err != nil {
+ clock = 3 * time.Hour
+ }
+ midnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
+ next := midnight.Add(clock)
+ interval := time.Duration(intervalHours) * time.Hour
+ for !next.After(from) {
+ next = next.Add(interval)
+ }
+ return next
+}
+
+func snapshotBaseDir() string {
+ return filepath.Join(config.AppConfig.DataDir, "snapshots")
+}
+
+func copyTree(src string, dst string) error {
+ if err := os.MkdirAll(dst, 0700); err != nil {
+ return err
+ }
+ output, err := exec.Command("cp", "-a", "--sparse=always", "--reflink=auto", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
+ if err != nil {
+ output, err = exec.Command("cp", "-a", "--sparse=always", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("cp failed: %v, output: %s", err, string(output))
+ }
+ }
+ return nil
+}
+
+func dirSizeBytes(path string) int64 {
+ out, err := exec.Command("du", "-s", "-B1", path).Output()
+ if err != nil {
+ return 0
+ }
+ parts := strings.Fields(string(out))
+ if len(parts) == 0 {
+ return 0
+ }
+ var size int64
+ fmt.Sscanf(parts[0], "%d", &size)
+ return size
+}
+
+func safePathUnder(path string, base string) error {
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return err
+ }
+ absBase, err := filepath.Abs(base)
+ if err != nil {
+ return err
+ }
+ if absPath == absBase || strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) {
+ return nil
+ }
+ return fmt.Errorf("refusing unsafe path: %s", absPath)
+}
+
+func sortSnapshotsOldestFirst(snapshots []config.Snapshot) {
+ sort.SliceStable(snapshots, func(i, j int) bool {
+ ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt)
+ tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt)
+ return ti.Before(tj)
+ })
+}
+
+func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
+ c := config.FindContainer(id)
+ if c == nil {
+ return nil, fmt.Errorf("container not found: %d", id)
+ }
+ name := c.VirshName()
+ cpuUsec, rxBytes, txBytes, readBytes, writeBytes := m.getUsageCounters(c)
+ usage := map[string]interface{}{
+ "memory_usage_bytes": int64(0),
+ "cpu_usage_usec": cpuUsec,
+ "cpu_usage_pct": 0.0,
+ "disk_usage_bytes": int64(0),
+ "network_rx_bytes": rxBytes,
+ "network_tx_bytes": txBytes,
+ "network_rx_bps": 0.0,
+ "network_tx_bps": 0.0,
+ "disk_read_bytes": readBytes,
+ "disk_write_bytes": writeBytes,
+ "disk_read_bps": 0.0,
+ "disk_write_bps": 0.0,
+ }
+ if c.DiskImage != "" {
+ if info, err := os.Stat(c.DiskImage); err == nil {
+ usage["disk_usage_bytes"] = info.Size()
+ }
+ }
+ if c.Status == "running" {
+ if mem := virshMemBytes(name); mem > 0 {
+ usage["memory_usage_bytes"] = mem
+ }
+ }
+ usageMu.RLock()
+ rate, hasRate := rateCache[name]
+ usageMu.RUnlock()
+ if hasRate && time.Since(rate.UpdatedAt) < 15*time.Second {
+ usage["cpu_usage_pct"] = rate.CPUPct
+ usage["network_rx_bps"] = rate.RXBps
+ usage["network_tx_bps"] = rate.TXBps
+ usage["disk_read_bps"] = rate.ReadBps
+ usage["disk_write_bps"] = rate.WriteBps
+ }
+ return usage, nil
+}
+
+func (m *Manager) ListContainers(containers []config.Container) []config.Container {
+ for i := range containers {
+ if !containers[i].IsKVM() {
+ continue
+ }
+ status, err := m.GetContainerStatus(containers[i].VirshName())
+ if err == nil && status != "" {
+ containers[i].Status = status
+ }
+ if status == "running" {
+ if ip, err := m.GetContainerIP(containers[i].VirshName()); err == nil && ip != "" {
+ containers[i].IP = ip
+ }
+ }
+ }
+ return containers
+}
+
+func (m *Manager) GetContainerStatus(name string) (string, error) {
+ cmd := exec.Command("virsh", "domstate", name)
+ out, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ state := strings.ToLower(strings.TrimSpace(string(out)))
+ if strings.Contains(state, "running") {
+ return "running", nil
+ }
+ if strings.Contains(state, "shut") || strings.Contains(state, "off") {
+ return "stopped", nil
+ }
+ return state, nil
+}
+
+func (m *Manager) GetContainerIP(name string) (string, error) {
+ for _, source := range []string{"lease", "arp", "agent"} {
+ cmd := exec.Command("virsh", "domifaddr", name, "--source", source)
+ out, err := cmd.Output()
+ if err != nil {
+ continue
+ }
+ if ip := firstIPv4(string(out)); ip != "" {
+ return ip, nil
+ }
+ }
+ if mac := domainMACAddress(name); mac != "" {
+ if ip := dhcpLeaseIP("default", mac); ip != "" {
+ return ip, nil
+ }
+ }
+ return "", fmt.Errorf("no IPv4 address found for %s", name)
+}
+
+func (m *Manager) validateHost() error {
+ for _, name := range []string{"virsh", "qemu-img", "cloud-localds"} {
+ if err := requireCommand(name); err != nil {
+ return err
+ }
+ }
+ if _, err := os.Stat("/dev/kvm"); err != nil {
+ return fmt.Errorf("KVM is not available: /dev/kvm not found")
+ }
+ if err := ensureDefaultNetwork(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func requireCommand(name string) error {
+ if _, err := exec.LookPath(name); err != nil {
+ return fmt.Errorf("%s is required for KVM support", name)
+ }
+ return nil
+}
+
+func ensureDefaultNetwork() error {
+ if exec.Command("virsh", "net-info", "default").Run() != nil {
+ return fmt.Errorf("libvirt default network is required for KVM support")
+ }
+ if exec.Command("virsh", "net-info", "default").Run() == nil {
+ out, _ := exec.Command("virsh", "net-info", "default").Output()
+ if !strings.Contains(strings.ToLower(string(out)), "active:") || !strings.Contains(strings.ToLower(string(out)), "yes") {
+ _ = exec.Command("virsh", "net-start", "default").Run()
+ }
+ _ = exec.Command("virsh", "net-autostart", "default").Run()
+ }
+ return nil
+}
+
+func createOverlayDisk(base, target string, diskGB int) error {
+ if diskGB < 1 {
+ diskGB = 5
+ }
+ cmd := exec.Command("qemu-img", "create", "-f", "qcow2", "-F", "qcow2", "-b", base, target)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("qemu-img create failed: %v, output: %s", err, string(output))
+ }
+ cmd = exec.Command("qemu-img", "resize", target, fmt.Sprintf("%dG", diskGB))
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("qemu-img resize failed: %v, output: %s", err, string(output))
+ }
+ _ = os.Chmod(target, 0644)
+ return nil
+}
+
+func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string) error {
+ setupScript := indentScript(kvmSSHSetupScript(password), 4)
+ userData := fmt.Sprintf(`#cloud-config
+preserve_hostname: false
+hostname: %s
+ssh_pwauth: true
+disable_root: false
+package_update: true
+chpasswd:
+ expire: false
+ users:
+ - name: root
+ password: %s
+ type: text
+users:
+ - name: root
+ lock_passwd: false
+runcmd:
+ - |
+%s
+`, hostname, password, setupScript)
+ metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
+ ipv6Block := ""
+ if strings.TrimSpace(ipv6) != "" {
+ ipv6Block = fmt.Sprintf(`
+ addresses:
+ - %s/128
+ routes:
+ - to: default
+ via: %s
+ metric: 100`, ipv6, ipv6GatewayLinkLocal)
+ }
+ networkConfig := fmt.Sprintf(`version: 2
+ethernets:
+ nic0:
+ match:
+ macaddress: "%s"
+ dhcp4: true
+ dhcp6: false%s
+`, strings.ToLower(mac), ipv6Block)
+ dir := filepath.Dir(seedPath)
+ userPath := filepath.Join(dir, "user-data")
+ metaPath := filepath.Join(dir, "meta-data")
+ networkPath := filepath.Join(dir, "network-config")
+ if err := os.WriteFile(userPath, []byte(userData), 0600); err != nil {
+ return err
+ }
+ if err := os.WriteFile(metaPath, []byte(metaData), 0600); err != nil {
+ return err
+ }
+ if err := os.WriteFile(networkPath, []byte(networkConfig), 0600); err != nil {
+ return err
+ }
+ cmd := exec.Command("cloud-localds", "--network-config="+networkPath, seedPath, userPath, metaPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("cloud-localds failed: %v, output: %s", err, string(output))
+ }
+ _ = os.Chmod(seedPath, 0644)
+ return nil
+}
+
+func indentScript(script string, spaces int) string {
+ prefix := strings.Repeat(" ", spaces)
+ lines := strings.Split(strings.TrimRight(script, "\n"), "\n")
+ for i, line := range lines {
+ lines[i] = prefix + line
+ }
+ return strings.Join(lines, "\n")
+}
+
+func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
+ if vcpu < 1 {
+ vcpu = 1
+ }
+ if ramMB < 512 {
+ ramMB = 512
+ }
+ iotune := ""
+ if ioSpeedMBps > 0 {
+ bytesPerSecond := int64(ioSpeedMBps) * 1024 * 1024
+ iotune = fmt.Sprintf(`
+
将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} 至 {form.name}-{batchStartIndex + batchCount - 1}
} +