初步支持KVM

This commit is contained in:
MengMengCode
2026-06-07 09:24:09 +08:00
parent 422e48b524
commit 6dd7079e23
22 changed files with 2986 additions and 192 deletions
+11 -16
View File
@@ -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
+104 -8
View File
@@ -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
}
+1 -1
View File
@@ -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
+172
View File
@@ -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)
}
+4 -4
View File
@@ -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
}
+62 -21
View File
@@ -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()
+16 -11
View File
@@ -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
}
+63
View File
@@ -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())
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
package kvm
import (
"path/filepath"
)
type Image struct {
ID string `json:"id"`
Name string `json:"name"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
Description string `json:"description"`
URL string `json:"url"`
}
func GetImages() []Image {
return []Image{
{
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
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-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
Description: "Ubuntu 22.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
},
{
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
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-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "amd64",
Description: "Debian 11 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2",
},
{
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
Distro: "rockylinux", Release: "9", Arch: "amd64",
Description: "Rocky Linux 9 GenericCloud image for KVM",
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
},
{
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
Distro: "centos", Release: "9-stream", Arch: "amd64",
Description: "CentOS Stream 9 GenericCloud image for KVM",
URL: "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2",
},
{
ID: "kvm-alpine-3.23", Name: "Alpine 3.23 KVM",
Distro: "alpine", Release: "3.23", Arch: "amd64",
Description: "Alpine Linux 3.23 NoCloud cloud-init image for KVM",
URL: "https://dev.alpinelinux.org/~tomalok/alpine-cloud-images/v3.23/nocloud/x86_64/nocloud_alpine-3.23.4-x86_64-bios-cloudinit-r0.qcow2",
},
}
}
func FindImage(id string) *Image {
for _, image := range GetImages() {
if image.ID == id {
return &image
}
}
return nil
}
func CacheDir() string {
return filepath.Join(BaseDir(), "images")
}
func ImagePath(id string) string {
return filepath.Join(CacheDir(), id+".qcow2")
}
+2 -2
View File
@@ -15,7 +15,7 @@ func IsExpired(c config.Container) bool {
// StopExpiredContainers stops running containers whose expiration date has passed.
func (m *Manager) StopExpiredContainers(now time.Time) {
for _, container := range config.AppConfig.Containers {
if !isContainerExpired(container, now) {
if container.IsKVM() || !isContainerExpired(container, now) {
continue
}
@@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) {
saved := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" {
if c.IsKVM() || c.Status != "running" {
continue
}
+17
View File
@@ -73,6 +73,9 @@ func (m *Manager) WarmRunningContainersSSH() {
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, container := range containers {
c := container
if c.IsKVM() {
continue
}
status, err := m.GetContainerStatus(c.LxcName())
if err != nil || status != "running" {
continue
@@ -102,6 +105,11 @@ func (m *Manager) updateAllRates() {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.IsKVM() {
delete(lastUsage, c.VirshName())
delete(rateCache, c.VirshName())
continue
}
if c.Status != "running" {
delete(lastUsage, c.LxcName())
delete(rateCache, c.LxcName())
@@ -211,6 +219,7 @@ func NewManager() *Manager {
// ContainerConfig defines container creation parameters
type ContainerConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
@@ -345,6 +354,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
ID: id,
UUID: config.NewContainerUUID(),
Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB,
@@ -1986,6 +1996,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
func (m *Manager) ListContainers() ([]config.Container, error) {
containers := config.AppConfig.Containers
for i := range containers {
if containers[i].IsKVM() {
continue
}
status, err := m.GetContainerStatus(containers[i].LxcName())
if err == nil {
containers[i].Status = status
@@ -2061,6 +2074,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
UUID: config.NewContainerUUID(),
Name: name,
LXCName: lxcName,
Virtualization: config.VirtualizationLXC,
Template: "imported",
VCPU: 1,
RAMMB: 512,
@@ -2579,6 +2593,9 @@ func (m *Manager) AccumulateTraffic() {
delete(lastTrafficSnapshot, c.LxcName())
continue
}
if c.IsKVM() {
continue
}
// Reset if new month
if c.TrafficResetDate != currentMonth {
c.TrafficUsedRX = 0
+24 -11
View File
@@ -18,8 +18,14 @@ func (m *Manager) ApplyPortMappings(id int) error {
return fmt.Errorf("container has no IP")
}
tag := clicdTag(id)
bridge := "lxcbr0"
subnet := "10.0.3.0/24"
if c.IsKVM() {
bridge = "virbr0"
subnet = "192.168.122.0/24"
}
EnsureForwardRules()
EnsureForwardRules(bridge)
m.CleanPortMappings(id)
for _, pm := range c.PortMappings {
@@ -41,8 +47,8 @@ func (m *Manager) ApplyPortMappings(id int) error {
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
}
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run()
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
}
return nil
@@ -50,18 +56,25 @@ func (m *Manager) ApplyPortMappings(id int) error {
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
func EnsureForwardRules() {
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
func EnsureForwardRules(bridge string) {
if bridge == "" {
bridge = "lxcbr0"
}
rules := [][]string{
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
{"-i", bridge, "-j", "ACCEPT"},
{"-o", bridge, "-j", "ACCEPT"},
{"-i", bridge, "-o", bridge, "-j", "ACCEPT"},
}
for _, args := range rules {
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
if exec.Command("iptables", checkArgs...).Run() != nil {
exec.Command("iptables", args...).Run()
for {
deleteArgs := append([]string{"-D", "FORWARD"}, args...)
if exec.Command("iptables", deleteArgs...).Run() != nil {
break
}
}
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
exec.Command("iptables", insertArgs...).Run()
}
}
+1 -1
View File
@@ -207,7 +207,7 @@ func (m *Manager) runDueSnapshotSchedules() {
now := time.Now()
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, c := range containers {
if !c.SnapshotScheduleEnabled {
if c.IsKVM() || !c.SnapshotScheduleEnabled {
continue
}
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
+1
View File
@@ -0,0 +1 @@