diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 92638e4..cec85b0 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -309,6 +309,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) return } + if err := validateCreateStoragePool(&cfg); err != nil { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()}) + return + } if err := validateCreateSSHAuth(cfg); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) return diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index 3790c73..522f73e 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "sync" "time" @@ -41,6 +42,9 @@ type ImageInfo struct { var imageDownloadsMu sync.Mutex var imageDownloads = map[string]*imageDownloadStatus{} +var lxcImageCacheMu sync.Mutex +var lxcImageDownloadMu sync.Mutex +var lxcImageDownloadActive bool type imageDownloadStatus struct { Downloading bool @@ -136,6 +140,22 @@ func isImageDownloadActive(id string) bool { return st != nil && st.Downloading } +func beginLXCImageDownload() bool { + lxcImageDownloadMu.Lock() + defer lxcImageDownloadMu.Unlock() + if lxcImageDownloadActive { + return false + } + lxcImageDownloadActive = true + return true +} + +func endLXCImageDownload() { + lxcImageDownloadMu.Lock() + lxcImageDownloadActive = false + lxcImageDownloadMu.Unlock() +} + func lxcImageDownloadTempName(id string) string { return fmt.Sprintf("clicd-img-dl-%s", id) } @@ -307,7 +327,6 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"}) return } - tmpl := lxc.FindTemplate(req.TemplateID) if tmpl == nil { image := kvm.FindImage(req.TemplateID) @@ -319,6 +338,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"}) return } + if _, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024); err != nil { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()}) + return + } if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok { ensureImageEnabled(image.ID) clearImageDownload(image.ID) @@ -359,6 +382,29 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"}) return } + if !beginLXCImageDownload() { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Another LXC image download is active"}) + return + } + lxcDownloadHandedOff := false + defer func() { + if !lxcDownloadHandedOff { + endLXCImageDownload() + } + }() + imagePool, err := config.SelectStoragePoolForContent( + config.StorageContentImages, + "", + dirSizeBytes("/var/cache/lxc/download")+1024*1024*1024, + ) + if err != nil { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()}) + return + } + if err := ensureLXCImageCachePool(*imagePool); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } // Already downloaded? Just enable if needed. if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) { @@ -375,6 +421,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { } go func(tmpl lxc.Template) { + defer endLXCImageDownload() // Download via lxc-create with a temp container, then destroy it. tmpName := lxcImageDownloadTempName(tmpl.ID) args := []string{"-n", tmpName, "-t", "download", "--", @@ -386,7 +433,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { st.Stage = "lxc-create" }) cmd := exec.CommandContext(ctx, "lxc-create", args...) - output, err := cmd.CombinedOutput() + output, err := runLXCImageDownloadCommand(cmd, tmpl.ID) // Clean up the temp container unconditionally. cleanupLXCImageDownloadTemp(tmpl.ID) @@ -403,10 +450,154 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { ensureImageEnabled(tmpl.ID) finishImageDownload(tmpl.ID, nil) }(*tmpl) + lxcDownloadHandedOff = true jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"}) } +type lxcImageDownloadCommandResult struct { + output []byte + err error +} + +func runLXCImageDownloadCommand(cmd *exec.Cmd, templateID string) ([]byte, error) { + startedAt := time.Now() + done := make(chan lxcImageDownloadCommandResult, 1) + go func() { + output, err := cmd.CombinedOutput() + done <- lxcImageDownloadCommandResult{output: output, err: err} + }() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + var lastBytes int64 + for { + select { + case result := <-done: + return result.output, result.err + case <-ticker.C: + downloadedBytes := newestLXCRootfsDownloadSize(startedAt) + if downloadedBytes <= 0 || downloadedBytes == lastBytes { + continue + } + lastBytes = downloadedBytes + updateImageDownload(templateID, func(st *imageDownloadStatus) { + st.Stage = "downloading" + st.DownloadedBytes = downloadedBytes + }) + } + } +} + +func newestLXCRootfsDownloadSize(startedAt time.Time) int64 { + matches, _ := filepath.Glob("/tmp/tmp.*/rootfs.tar.xz") + var newestTime time.Time + var newestSize int64 + for _, match := range matches { + info, err := os.Stat(match) + if err != nil || info.IsDir() || info.ModTime().Before(startedAt.Add(-5*time.Second)) { + continue + } + if info.ModTime().After(newestTime) { + newestTime = info.ModTime() + newestSize = info.Size() + } + } + return newestSize +} + +func ensureLXCImageCachePool(pool config.StoragePool) error { + lxcImageCacheMu.Lock() + defer lxcImageCacheMu.Unlock() + + cachePath := "/var/cache/lxc/download" + targetPath := filepath.Join(pool.Path, "images", "lxc") + targetAbs, err := filepath.Abs(targetPath) + if err != nil { + return err + } + if err := os.MkdirAll(targetAbs, 0755); err != nil { + return fmt.Errorf("failed to create LXC image storage: %v", err) + } + + info, err := os.Lstat(cachePath) + if os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil { + return err + } + return os.Symlink(targetAbs, cachePath) + } + if err != nil { + return err + } + + sourcePath := cachePath + linked := info.Mode()&os.ModeSymlink != 0 + if linked { + sourcePath, err = filepath.EvalSymlinks(cachePath) + if err != nil { + return fmt.Errorf("failed to resolve LXC image cache: %v", err) + } + } + sourceAbs, err := filepath.Abs(sourcePath) + if err != nil { + return err + } + if sourceAbs == targetAbs { + return nil + } + if strings.HasPrefix(targetAbs, sourceAbs+string(os.PathSeparator)) || strings.HasPrefix(sourceAbs, targetAbs+string(os.PathSeparator)) { + return fmt.Errorf("LXC image cache source and target must not be nested") + } + if !info.IsDir() && !linked { + return fmt.Errorf("LXC image cache is not a directory: %s", cachePath) + } + + if output, err := exec.Command("cp", "-a", sourceAbs+string(os.PathSeparator)+".", targetAbs+string(os.PathSeparator)).CombinedOutput(); err != nil { + return fmt.Errorf("failed to migrate LXC image cache: %v, output: %s", err, strings.TrimSpace(string(output))) + } + + tempLink := fmt.Sprintf("%s.clicd-new-%d", cachePath, time.Now().UnixNano()) + if err := os.Symlink(targetAbs, tempLink); err != nil { + return err + } + if linked { + if err := os.Rename(tempLink, cachePath); err != nil { + _ = os.Remove(tempLink) + return fmt.Errorf("failed to switch LXC image cache: %v", err) + } + if isManagedLXCImageCachePath(sourceAbs) { + _ = os.RemoveAll(sourceAbs) + } + return nil + } + + backupPath := fmt.Sprintf("%s.clicd-backup-%d", cachePath, time.Now().UnixNano()) + if err := os.Rename(cachePath, backupPath); err != nil { + _ = os.Remove(tempLink) + return fmt.Errorf("failed to prepare LXC image cache migration: %v", err) + } + if err := os.Rename(tempLink, cachePath); err != nil { + _ = os.Rename(backupPath, cachePath) + _ = os.Remove(tempLink) + return fmt.Errorf("failed to activate LXC image storage: %v", err) + } + if err := os.RemoveAll(backupPath); err != nil { + return fmt.Errorf("LXC image cache migrated but old cache cleanup failed: %v", err) + } + return nil +} + +func isManagedLXCImageCachePath(path string) bool { + path = filepath.Clean(path) + for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) { + if path == filepath.Clean(filepath.Join(pool.Path, "images", "lxc")) { + return true + } + } + return path == filepath.Clean("/var/lib/clicd/images/lxc") +} + // HandleImageCancel cancels an in-progress image download. func HandleImageCancel(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/backend/internal/api/runtime.go b/backend/internal/api/runtime.go index 5327099..44b821f 100644 --- a/backend/internal/api/runtime.go +++ b/backend/internal/api/runtime.go @@ -147,12 +147,12 @@ func trafficByRuntime(id int) map[string]interface{} { return lxcManager.GetTrafficInfo(id) } -func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) { +func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) { c := config.FindContainer(id) if c != nil && c.IsKVM() { - return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit) + return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...) } - return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit) + return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...) } func deleteSnapshotByRuntime(snapshotID string) error { diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 0cc12e9..77cfb65 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "net/http" "time" @@ -48,6 +49,38 @@ func HandleLanguage(w http.ResponseWriter, r *http.Request) { } } +// HandleTaskQueueSettings returns or updates the global task concurrency limit. +func HandleTaskQueueSettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: globalQueue.Settings()}) + case http.MethodPut, http.MethodPost: + var req struct { + Concurrency int `json:"concurrency"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + if req.Concurrency < 1 || req.Concurrency > config.MaxTaskConcurrency { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "任务并发数必须在 1 到 16 之间"}) + return + } + previous := config.AppConfig.TaskConcurrency + config.AppConfig.TaskConcurrency = req.Concurrency + if err := config.SaveConfig(); err != nil { + config.AppConfig.TaskConcurrency = previous + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存任务队列设置失败"}) + return + } + globalQueue.SetConcurrency(req.Concurrency) + auditRequest(r, "settings.task_queue", "task_concurrency", fmt.Sprintf("concurrency=%d", req.Concurrency), true, "") + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "任务队列设置已保存", Data: globalQueue.Settings()}) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + // RecordLoginLog adds a login attempt to the log (persisted to config) func RecordLoginLog(username, ip, userAgent string, success bool) { config.AddLoginLog(username, ip, userAgent, success) diff --git a/backend/internal/api/snapshots.go b/backend/internal/api/snapshots.go index 9574266..d4ab70f 100644 --- a/backend/internal/api/snapshots.go +++ b/backend/internal/api/snapshots.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "io" "net/http" "sort" "strconv" @@ -88,6 +89,20 @@ func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) { user := requestUser(r) + var req struct { + StoragePoolID string `json:"storage_pool_id"` + } + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + } + req.StoragePoolID = strings.TrimSpace(req.StoragePoolID) + if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, req.StoragePoolID, 0); err != nil { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()}) + return + } if isSubUserRequest(r) { c := config.FindContainer(containerID) limit := config.ContainerSnapshotLimit(c) @@ -96,7 +111,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID return } } - snapshot, err := createSnapshotByRuntime(containerID, user, false, 0) + snapshot, err := createSnapshotByRuntime(containerID, user, false, 0, req.StoragePoolID) if err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return @@ -159,6 +174,12 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID if req.Time == "" { req.Time = "03:00" } + if req.Enabled { + if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, "", 0); err != nil { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()}) + return + } + } user := requestUser(r) c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user) if err != nil { diff --git a/backend/internal/api/storage.go b/backend/internal/api/storage.go new file mode 100644 index 0000000..11e96d3 --- /dev/null +++ b/backend/internal/api/storage.go @@ -0,0 +1,445 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + pathpkg "path" + "path/filepath" + "strings" + + "clicd/internal/config" +) + +type storageInfoResponse struct { + Pools []storagePoolInfo `json:"pools"` + Disks []storageDiskInfo `json:"disks"` + ContentTypes []string `json:"content_types"` +} + +type storagePoolInfo struct { + config.StoragePool + Available bool `json:"available"` + Exists bool `json:"exists"` + SizeBytes int64 `json:"size_bytes"` + UsedBytes int64 `json:"used_bytes"` + FreeBytes int64 `json:"free_bytes"` + ClicdUsedBytes int64 `json:"clicd_used_bytes"` + ContentUsage []storageContentUsage `json:"content_usage"` + Error string `json:"error,omitempty"` +} + +type storageContentUsage struct { + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` +} + +type storageDiskInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + FSType string `json:"fstype"` + MountPoint string `json:"mount_point"` + Model string `json:"model"` + SizeBytes int64 `json:"size_bytes"` + UsedBytes int64 `json:"used_bytes"` + FreeBytes int64 `json:"free_bytes"` + StoragePoolID string `json:"storage_pool_id,omitempty"` + StoragePath string `json:"storage_path,omitempty"` + ClicdUsedBytes int64 `json:"clicd_used_bytes"` + ContentUsage []storageContentUsage `json:"content_usage"` +} + +func HandleStorage(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()}) + case http.MethodPut: + var req struct { + Pools []config.StoragePool `json:"pools"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + pools, err := normalizeStoragePoolsRequest(req.Pools) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + for _, pool := range pools { + if err := os.MkdirAll(pool.Path, 0755); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: fmt.Sprintf("Failed to create %s: %v", pool.Path, err)}) + return + } + } + config.AppConfig.StoragePools = pools + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save storage pools"}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()}) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + +func buildStorageInfo() storageInfoResponse { + disks := detectStorageDisks() + pools := make([]storagePoolInfo, 0, len(config.AppConfig.StoragePools)) + for _, pool := range config.AppConfig.StoragePools { + info := storagePoolInfo{StoragePool: pool} + if filepath.Clean(pool.MountPoint) == string(os.PathSeparator) { + _ = os.MkdirAll(pool.Path, 0755) + } + if st, err := os.Stat(pool.Path); err == nil && st.IsDir() { + info.Exists = true + } else if err != nil { + info.Error = err.Error() + } + detectedMountPoint := bestMountPointForPath(pool.Path, disks) + if info.MountPoint == "" { + info.MountPoint = detectedMountPoint + } + if detectedMountPoint != "" && filepath.Clean(info.MountPoint) == filepath.Clean(detectedMountPoint) { + info.Available = info.Exists + info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(pool.Path) + info.ContentUsage, info.ClicdUsedBytes = contentUsageForPool(pool.Path) + } else if info.Error == "" { + info.Error = "storage disk is not mounted" + } + pools = append(pools, info) + } + for i := range disks { + for _, pool := range pools { + if pool.MountPoint != disks[i].MountPoint { + continue + } + disks[i].ClicdUsedBytes += pool.ClicdUsedBytes + disks[i].ContentUsage = mergeContentUsage(disks[i].ContentUsage, pool.ContentUsage) + if disks[i].StoragePoolID == "" { + disks[i].StoragePoolID = pool.ID + disks[i].StoragePath = pool.Path + } + } + } + return storageInfoResponse{ + Pools: pools, + Disks: disks, + ContentTypes: []string{ + config.StorageContentLXC, + config.StorageContentKVM, + config.StorageContentImages, + config.StorageContentSnapshots, + config.StorageContentBackups, + }, + } +} + +func normalizeStoragePoolsRequest(items []config.StoragePool) ([]config.StoragePool, error) { + if len(items) == 0 { + return nil, fmt.Errorf("at least one mounted storage disk configuration must be retained") + } + result := make([]config.StoragePool, 0, len(items)) + seen := map[string]bool{} + defaultSeen := map[string]bool{} + disks := detectStorageDisks() + for _, item := range items { + item.ID = strings.TrimSpace(item.ID) + item.Name = strings.TrimSpace(item.Name) + item.Path = filepath.Clean(strings.TrimSpace(item.Path)) + item.MountPoint = filepath.Clean(strings.TrimSpace(item.MountPoint)) + if item.MountPoint == "." { + item.MountPoint = "" + } + if item.Name == "" { + return nil, fmt.Errorf("storage pool name is required") + } + if item.ID == "" { + item.ID = storageID(item.Name) + } + if seen[item.ID] { + return nil, fmt.Errorf("duplicate storage pool ID: %s", item.ID) + } + seen[item.ID] = true + if !filepath.IsAbs(item.Path) { + return nil, fmt.Errorf("%s path must be absolute", item.Name) + } + detectedMountPoint := bestMountPointForPath(item.Path, disks) + if detectedMountPoint == "" { + return nil, fmt.Errorf("%s path is not on an available mounted storage disk", item.Name) + } + if item.MountPoint != "" && filepath.Clean(item.MountPoint) != filepath.Clean(detectedMountPoint) { + return nil, fmt.Errorf("%s storage disk mount point has changed; refresh and try again", item.Name) + } + item.MountPoint = detectedMountPoint + item.ContentTypes = normalizeStorageContentTypes(item.ContentTypes) + item.DefaultContents = normalizeStorageContentTypes(item.DefaultContents) + allowed := map[string]bool{} + for _, content := range item.ContentTypes { + allowed[content] = true + } + defaults := make([]string, 0, len(item.DefaultContents)) + for _, content := range item.DefaultContents { + if !allowed[content] { + continue + } + if defaultSeen[content] { + return nil, fmt.Errorf("only one default storage disk is allowed for %s", content) + } + defaultSeen[content] = true + defaults = append(defaults, content) + } + item.DefaultContents = defaults + result = append(result, item) + } + return result, nil +} + +func normalizeStorageContentTypes(values []string) []string { + valid := map[string]bool{ + config.StorageContentLXC: true, + config.StorageContentKVM: true, + config.StorageContentImages: true, + config.StorageContentSnapshots: true, + config.StorageContentBackups: true, + } + seen := map[string]bool{} + result := []string{} + for _, value := range values { + next := strings.ToLower(strings.TrimSpace(value)) + if !valid[next] || seen[next] { + continue + } + seen[next] = true + result = append(result, next) + } + return result +} + +func storageID(name string) string { + id := strings.ToLower(strings.TrimSpace(name)) + id = strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-").Replace(id) + id = strings.Trim(id, "-") + if id == "" { + return "storage" + } + return id +} + +func detectStorageDisks() []storageDiskInfo { + type lsblkDevice struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + FSType string `json:"fstype"` + MountPoint string `json:"mountpoint"` + Model string `json:"model"` + Size int64 `json:"size"` + ReadOnly bool `json:"ro"` + Children []lsblkDevice `json:"children"` + } + var payload struct { + BlockDevices []lsblkDevice `json:"blockdevices"` + } + out, err := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RO").Output() + if err != nil { + return nil + } + if err := json.Unmarshal(out, &payload); err != nil { + return nil + } + result := []storageDiskInfo{} + var walk func(lsblkDevice) + walk = func(dev lsblkDevice) { + info := storageDiskInfo{ + Name: dev.Name, + Path: dev.Path, + Type: dev.Type, + FSType: dev.FSType, + MountPoint: dev.MountPoint, + Model: strings.TrimSpace(dev.Model), + SizeBytes: dev.Size, + } + if isUsableStorageMount(dev.Type, dev.FSType, dev.Path, dev.MountPoint, dev.ReadOnly) && !mountIsReadOnly(dev.MountPoint) { + info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(dev.MountPoint) + result = append(result, info) + } + for _, child := range dev.Children { + walk(child) + } + } + for _, dev := range payload.BlockDevices { + walk(dev) + } + return result +} + +func isUsableStorageMount(deviceType, fsType, devicePath, mountPoint string, readOnly bool) bool { + if readOnly || strings.TrimSpace(mountPoint) == "" || !strings.HasPrefix(mountPoint, "/") { + return false + } + + deviceType = strings.ToLower(strings.TrimSpace(deviceType)) + devicePath = strings.ToLower(strings.TrimSpace(devicePath)) + if deviceType == "loop" || deviceType == "rom" || deviceType == "zram" || strings.HasPrefix(devicePath, "/dev/loop") { + return false + } + + fsType = strings.ToLower(strings.TrimSpace(fsType)) + unsupportedFileSystems := map[string]bool{ + "": true, + "squashfs": true, + "iso9660": true, + "udf": true, + "swap": true, + "tmpfs": true, + "devtmpfs": true, + "overlay": true, + "proc": true, + "sysfs": true, + "cgroup": true, + "cgroup2": true, + "efivarfs": true, + "securityfs": true, + } + if unsupportedFileSystems[fsType] { + return false + } + + mountPoint = pathpkg.Clean(mountPoint) + for _, reserved := range []string{"/snap", "/boot"} { + if mountPoint == reserved || strings.HasPrefix(mountPoint, reserved+"/") { + return false + } + } + return true +} + +func mountIsReadOnly(mountPoint string) bool { + out, err := exec.Command("findmnt", "-n", "-o", "OPTIONS", "--target", mountPoint).Output() + if err != nil { + return false + } + for _, option := range strings.Split(strings.TrimSpace(string(out)), ",") { + if strings.TrimSpace(option) == "ro" { + return true + } + } + return false +} + +func contentUsageForPool(poolPath string) ([]storageContentUsage, int64) { + mapping := map[string]string{ + config.StorageContentLXC: "lxc", + config.StorageContentKVM: "kvm", + config.StorageContentImages: "images", + config.StorageContentSnapshots: "snapshots", + config.StorageContentBackups: "backups", + } + result := make([]storageContentUsage, 0, len(mapping)) + var total int64 + for _, content := range []string{ + config.StorageContentLXC, + config.StorageContentKVM, + config.StorageContentImages, + config.StorageContentSnapshots, + config.StorageContentBackups, + } { + size := dirSizeBytes(filepath.Join(poolPath, mapping[content])) + result = append(result, storageContentUsage{ContentType: content, SizeBytes: size}) + total += size + } + return result, total +} + +func mergeContentUsage(current []storageContentUsage, next []storageContentUsage) []storageContentUsage { + sizes := map[string]int64{} + order := []string{} + for _, item := range append(current, next...) { + if _, ok := sizes[item.ContentType]; !ok { + order = append(order, item.ContentType) + } + sizes[item.ContentType] += item.SizeBytes + } + result := make([]storageContentUsage, 0, len(order)) + for _, content := range order { + result = append(result, storageContentUsage{ContentType: content, SizeBytes: sizes[content]}) + } + return result +} + +func dirSizeBytes(path string) int64 { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + // Count allocated blocks on this filesystem only. LXC rootfs directories can + // contain active mounts such as proc/sys; traversing them is slow and reports + // enormous virtual sizes that are not actually occupied by CLICD data. + out, err := exec.Command("du", "-skx", path).Output() + if err == nil { + fields := strings.Fields(string(out)) + if len(fields) > 0 { + var sizeKB int64 + if _, scanErr := fmt.Sscanf(fields[0], "%d", &sizeKB); scanErr == nil && sizeKB <= (1<<63-1)/1024 { + return sizeKB * 1024 + } + } + } + var size int64 + _ = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if info, statErr := d.Info(); statErr == nil { + size += info.Size() + } + return nil + }) + return size +} + +func dfPath(path string) (size int64, used int64, free int64) { + out, err := exec.Command("df", "-B1", "-P", path).Output() + if err != nil { + return 0, 0, 0 + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 2 { + return 0, 0, 0 + } + fields := strings.Fields(lines[len(lines)-1]) + if len(fields) < 6 { + return 0, 0, 0 + } + fmt.Sscanf(fields[1], "%d", &size) + fmt.Sscanf(fields[2], "%d", &used) + fmt.Sscanf(fields[3], "%d", &free) + return size, used, free +} + +func bestMountPointForPath(path string, disks []storageDiskInfo) string { + path = strings.ReplaceAll(path, "\\", "/") + path = pathpkg.Clean(path) + best := "" + for _, disk := range disks { + mp := pathpkg.Clean(strings.ReplaceAll(disk.MountPoint, "\\", "/")) + if disk.MountPoint == "" || mp == "." { + continue + } + matches := path == mp + if mp == "/" { + matches = pathpkg.IsAbs(path) + } else if strings.HasPrefix(path, mp+"/") { + matches = true + } + if matches { + if len(mp) > len(best) { + best = mp + } + } + } + return best +} diff --git a/backend/internal/api/storage_test.go b/backend/internal/api/storage_test.go new file mode 100644 index 0000000..75eca59 --- /dev/null +++ b/backend/internal/api/storage_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestIsUsableStorageMount(t *testing.T) { + tests := []struct { + name string + deviceType string + fsType string + devicePath string + mountPoint string + readOnly bool + wantUsable bool + }{ + {name: "root partition", deviceType: "part", fsType: "ext4", devicePath: "/dev/sda2", mountPoint: "/", wantUsable: true}, + {name: "mounted data disk", deviceType: "disk", fsType: "xfs", devicePath: "/dev/sdb", mountPoint: "/data", wantUsable: true}, + {name: "snap loop", deviceType: "loop", fsType: "squashfs", devicePath: "/dev/loop0", mountPoint: "/snap/core20/2105", readOnly: true}, + {name: "loop without ro flag", deviceType: "loop", fsType: "ext4", devicePath: "/dev/loop7", mountPoint: "/mnt/loop"}, + {name: "read only disk", deviceType: "part", fsType: "ext4", devicePath: "/dev/sdc1", mountPoint: "/archive", readOnly: true}, + {name: "optical image", deviceType: "rom", fsType: "iso9660", devicePath: "/dev/sr0", mountPoint: "/media/cdrom"}, + {name: "efi partition", deviceType: "part", fsType: "vfat", devicePath: "/dev/sda1", mountPoint: "/boot/efi"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isUsableStorageMount(tt.deviceType, tt.fsType, tt.devicePath, tt.mountPoint, tt.readOnly) + if got != tt.wantUsable { + t.Fatalf("isUsableStorageMount() = %v, want %v", got, tt.wantUsable) + } + }) + } +} + +func TestBestMountPointForPath(t *testing.T) { + disks := []storageDiskInfo{ + {Path: "/dev/sda2", MountPoint: "/"}, + {Path: "/dev/sdb1", MountPoint: "/mnt/clicd-data"}, + } + tests := []struct { + path string + want string + }{ + {path: "/var/lib/clicd", want: "/"}, + {path: "/mnt/clicd-data/clicd", want: "/mnt/clicd-data"}, + {path: "/mnt/clicd-data", want: "/mnt/clicd-data"}, + } + for _, tt := range tests { + if got := bestMountPointForPath(tt.path, disks); got != tt.want { + t.Fatalf("bestMountPointForPath(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + +func TestDirSizeBytesUsesAllocatedBlocks(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("allocated-block behavior is provided by the Linux du command") + } + dir := t.TempDir() + file, err := os.Create(filepath.Join(dir, "sparse.img")) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(1 << 30); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + if got := dirSizeBytes(dir); got >= 128<<20 { + t.Fatalf("dirSizeBytes() = %d, expected allocated size instead of 1 GiB apparent size", got) + } +} diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index 89d2cf4..ff0f175 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -30,6 +30,8 @@ type Task struct { ContainerName string `json:"container_name"` Status string `json:"status"` Error string `json:"error,omitempty"` + Stage string `json:"stage,omitempty"` + StageDetail string `json:"stage_detail,omitempty"` CreatedAt string `json:"created_at"` TemplateID string `json:"template_id,omitempty"` Config lxc.ContainerConfig `json:"config,omitempty"` @@ -37,30 +39,74 @@ type Task struct { User string `json:"user,omitempty"` // who created this task IP string `json:"ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` + activeKey string } type TaskQueue struct { - mu sync.Mutex - createQueue []*Task - opQueue []*Task - tasks map[string]*Task - nextID int - createCond *sync.Cond - opCond *sync.Cond - stop chan struct{} + mu sync.Mutex + createQueue []*Task + opQueue []*Task + tasks map[string]*Task + nextID int + createCond *sync.Cond + opCond *sync.Cond + maxConcurrency int + activeTasks int + activeTargets map[string]bool + stop chan struct{} +} + +type TaskQueueSettings struct { + Concurrency int `json:"concurrency"` + Active int `json:"active"` + Pending int `json:"pending"` } var globalQueue *TaskQueue func init() { - globalQueue = &TaskQueue{ - tasks: make(map[string]*Task), - stop: make(chan struct{}), + globalQueue = newTaskQueue(config.DefaultTaskConcurrency) + go globalQueue.createDispatcher() + go globalQueue.opDispatcher() +} + +func newTaskQueue(concurrency int) *TaskQueue { + q := &TaskQueue{ + tasks: make(map[string]*Task), + maxConcurrency: config.NormalizeTaskConcurrency(concurrency), + activeTargets: make(map[string]bool), + stop: make(chan struct{}), } - globalQueue.createCond = sync.NewCond(&globalQueue.mu) - globalQueue.opCond = sync.NewCond(&globalQueue.mu) - go globalQueue.createWorker() - go globalQueue.opWorker() + q.createCond = sync.NewCond(&q.mu) + q.opCond = sync.NewCond(&q.mu) + return q +} + +func ConfigureTaskQueue(concurrency int) { + globalQueue.SetConcurrency(concurrency) +} + +func (q *TaskQueue) SetConcurrency(concurrency int) { + q.mu.Lock() + q.maxConcurrency = config.NormalizeTaskConcurrency(concurrency) + q.createCond.Broadcast() + q.opCond.Broadcast() + q.mu.Unlock() +} + +func (q *TaskQueue) Settings() TaskQueueSettings { + q.mu.Lock() + defer q.mu.Unlock() + return TaskQueueSettings{ + Concurrency: q.maxConcurrency, + Active: q.activeTasks, + Pending: len(q.createQueue) + len(q.opQueue), + } +} + +func (q *TaskQueue) signalDispatchers() { + q.createCond.Broadcast() + q.opCond.Broadcast() } func (q *TaskQueue) enqueueTask(task *Task) { @@ -90,6 +136,8 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task ContainerID: containerID, ContainerName: containerName, Status: "pending", + Stage: "queued", + StageDetail: "排队等待", CreatedAt: time.Now().Format("2006-01-02 15:04:05"), TemplateID: templateID, User: user, @@ -172,6 +220,8 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s ContainerID: 0, ContainerName: cfgCopy.Name, Status: "pending", + Stage: "queued", + StageDetail: "排队等待", CreatedAt: time.Now().Format("2006-01-02 15:04:05"), Config: cfgCopy, User: user, @@ -202,6 +252,8 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string ContainerID: containerID, ContainerName: containerName, Status: "pending", + Stage: "queued", + StageDetail: "排队等待", CreatedAt: time.Now().Format("2006-01-02 15:04:05"), TemplateID: templateID, User: user, @@ -262,176 +314,251 @@ func (q *TaskQueue) CancelPendingSecurityStops() int { return cancelled } -// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init. -// If a restored task already has a same-name container in config, it resumes -// initialization instead of creating another ct-{id}. -func (q *TaskQueue) createWorker() { +// The two dispatchers keep long-running creates from blocking power operations, +// while sharing one global concurrency budget. +func (q *TaskQueue) createDispatcher() { for { - q.mu.Lock() - for len(q.createQueue) == 0 { - q.createCond.Wait() - } - task := q.createQueue[0] - q.createQueue = q.createQueue[1:] - task.Status = "running" - q.mu.Unlock() - - createdByTask := false - if task.Config.Name == "" { - task.Config.Name = task.ContainerName - } - task.Config.NormalizeResourceAliases() - if task.Config.Name == "" { - task.Status = "failed" - task.Error = "container name is required" - config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin") - q.mu.Lock() - q.persistTasks() - q.mu.Unlock() - continue - } - c := config.FindContainerByName(task.Config.Name) - if c == nil { - // 1) Download image + apply limits (lxc-create) - err := createByRuntime(task.Config) - if err != nil { - task.Status = "failed" - task.Error = err.Error() - config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin") - q.mu.Lock() - q.persistTasks() - q.mu.Unlock() - continue - } - createdByTask = true - - // 2) Find created container by name - c = config.FindContainerByName(task.Config.Name) - if c == nil { - task.Status = "failed" - task.Error = "created but not found in config" - config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin") - q.mu.Lock() - q.persistTasks() - q.mu.Unlock() - continue - } - } - - task.ContainerID = c.ID - task.ContainerName = c.Name - - // 3) Start + initialize SSH/network in the same worker. - // If init fails, destroy the container so no dead entry remains. - startErr := startByRuntime(c.ID) - if startErr != nil { - if createdByTask { - _ = destroyByRuntime(c.ID) - } - task.Status = "failed" - task.Error = startErr.Error() - config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin") - } else { - task.Status = "done" - config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin") - } - - q.mu.Lock() - q.persistTasks() - q.mu.Unlock() + task := q.takeNextTask(true) + go q.runCreateTask(task) } } -// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall) -// including the follow-up initialization after a create succeeds. -func (q *TaskQueue) opWorker() { +func (q *TaskQueue) opDispatcher() { for { - q.mu.Lock() - for len(q.opQueue) == 0 { - q.opCond.Wait() - } - task := q.opQueue[0] - q.opQueue = q.opQueue[1:] - task.Status = "running" - q.mu.Unlock() - - var err error - skipped := false - err = resolveTaskContainer(task) - // Block operations on expired or traffic-exceeded containers (except stop/delete) - if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) { - c := config.FindContainer(task.ContainerID) - if c != nil { - if lxc.IsExpired(*c) { - err = fmt.Errorf("容器已到期,不允许此操作") - } else if lxc.IsTrafficExceeded(*c) { - err = fmt.Errorf("容器流量已超限,不允许此操作") - } - } - } - if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown { - skipped = true - } - if err == nil { - if !skipped { - switch task.Type { - case TaskStart: - err = startByRuntime(task.ContainerID) - case TaskStop: - err = stopByRuntime(task.ContainerID) - case TaskRestart: - err = restartByRuntime(task.ContainerID) - case TaskDelete: - err = destroyByRuntime(task.ContainerID) - if err == nil { - time.Sleep(1 * time.Second) - if config.FindContainer(task.ContainerID) != nil { - err = fmt.Errorf("container still exists after delete: %d", task.ContainerID) - } - } - case TaskReinstall: - if lxc.HasSSHAuthOptions(task.Config) { - err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config) - } else { - err = reinstallByRuntime(task.ContainerID, task.TemplateID) - } - } - } - } - - q.mu.Lock() - auditUser := task.User - if auditUser == "" { - auditUser = "admin" - } - if err != nil { - task.Status = "failed" - task.Error = err.Error() - config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error()) - } else if skipped { - task.Status = "done" - config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "") - } else { - task.Status = "done" - config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "") - switch task.Type { - case TaskStart: - config.UpdateContainerStatus(task.ContainerID, "running") - clearPolicyBlockAfterAdminRecovery(task) - case TaskStop: - config.UpdateContainerStatus(task.ContainerID, "stopped") - case TaskRestart: - config.UpdateContainerStatus(task.ContainerID, "running") - clearPolicyBlockAfterAdminRecovery(task) - case TaskReinstall: - clearPolicyBlockAfterAdminRecovery(task) - } - } - q.persistTasks() - q.mu.Unlock() + task := q.takeNextTask(false) + go q.runOperationTask(task) } } +func (q *TaskQueue) takeNextTask(create bool) *Task { + q.mu.Lock() + defer q.mu.Unlock() + cond := q.opCond + if create { + cond = q.createCond + } + for { + queue := q.opQueue + if create { + queue = q.createQueue + } + if q.activeTasks < q.maxConcurrency { + if index := runnableTaskIndex(queue, q.activeTargets); index >= 0 { + task := queue[index] + queue = append(queue[:index], queue[index+1:]...) + if create { + q.createQueue = queue + } else { + q.opQueue = queue + } + task.Status = "running" + task.Error = "" + task.Stage = "preparing" + task.StageDetail = "准备初始化环境" + task.activeKey = taskConcurrencyKey(task) + q.activeTargets[task.activeKey] = true + q.activeTasks++ + q.persistTasks() + return task + } + } + cond.Wait() + } +} + +func runnableTaskIndex(queue []*Task, activeTargets map[string]bool) int { + for index, task := range queue { + if !activeTargets[taskConcurrencyKey(task)] { + return index + } + } + return -1 +} + +func taskConcurrencyKey(task *Task) string { + if task == nil { + return "task:nil" + } + name := strings.TrimSpace(task.ContainerName) + if name == "" { + name = strings.TrimSpace(task.Config.Name) + } + if name != "" { + return "name:" + strings.ToLower(name) + } + if task.ContainerID > 0 { + return fmt.Sprintf("id:%d", task.ContainerID) + } + return "task:" + task.ID +} + +func (q *TaskQueue) finishTask(task *Task, status string, taskErr error) { + q.mu.Lock() + task.Status = status + if taskErr != nil { + task.Error = taskErr.Error() + if task.Type == TaskCreate { + task.Stage = "failed" + task.StageDetail = "初始化失败" + } + } else { + task.Error = "" + if task.Type == TaskCreate { + task.Stage = "completed" + task.StageDetail = "初始化完成" + } + } + if task.activeKey != "" { + delete(q.activeTargets, task.activeKey) + task.activeKey = "" + } + if q.activeTasks > 0 { + q.activeTasks-- + } + q.persistTasks() + q.signalDispatchers() + q.mu.Unlock() +} + +func (q *TaskQueue) updateTaskStage(task *Task, stage, detail string) { + q.mu.Lock() + task.Stage = stage + task.StageDetail = detail + q.mu.Unlock() +} + +// runCreateTask handles lxc-create, resource setup, start, and SSH init. A +// restored task resumes initialization when the same-name container exists. +func (q *TaskQueue) runCreateTask(task *Task) { + q.mu.Lock() + createdByTask := false + if task.Config.Name == "" { + task.Config.Name = task.ContainerName + } + task.Config.NormalizeResourceAliases() + cfg := task.Config + q.mu.Unlock() + cfg.Progress = func(stage, detail string) { + q.updateTaskStage(task, stage, detail) + } + if cfg.Name == "" { + err := fmt.Errorf("container name is required") + config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+err.Error(), "admin") + q.finishTask(task, "failed", err) + return + } + c := config.FindContainerByName(cfg.Name) + if c == nil { + if err := createByRuntime(cfg); err != nil { + config.AddAuditLog(string(task.Type), cfg.Name, "失败: "+err.Error(), "admin") + q.finishTask(task, "failed", err) + return + } + createdByTask = true + c = config.FindContainerByName(cfg.Name) + if c == nil { + err := fmt.Errorf("created but not found in config") + config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin") + q.finishTask(task, "failed", err) + return + } + } + + q.mu.Lock() + task.ContainerID = c.ID + task.ContainerName = c.Name + q.mu.Unlock() + startDetail := "启动容器并等待网络就绪" + if strings.EqualFold(cfg.Virtualization, config.VirtualizationKVM) { + startDetail = "启动虚拟机并等待网络就绪" + } + q.updateTaskStage(task, "starting", startDetail) + if err := startByRuntime(c.ID); err != nil { + if createdByTask { + _ = destroyByRuntime(c.ID) + } + config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+err.Error(), "admin") + q.finishTask(task, "failed", err) + return + } + config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin") + q.finishTask(task, "done", nil) +} + +func (q *TaskQueue) runOperationTask(task *Task) { + q.mu.Lock() + err := resolveTaskContainer(task) + q.mu.Unlock() + skipped := false + if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) { + c := config.FindContainer(task.ContainerID) + if c != nil { + if lxc.IsExpired(*c) { + err = fmt.Errorf("容器已到期,不允许此操作") + } else if lxc.IsTrafficExceeded(*c) { + err = fmt.Errorf("容器流量已超限,不允许此操作") + } + } + } + if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown { + skipped = true + } + if err == nil && !skipped { + switch task.Type { + case TaskStart: + err = startByRuntime(task.ContainerID) + case TaskStop: + err = stopByRuntime(task.ContainerID) + case TaskRestart: + err = restartByRuntime(task.ContainerID) + case TaskDelete: + err = destroyByRuntime(task.ContainerID) + if err == nil { + time.Sleep(time.Second) + if config.FindContainer(task.ContainerID) != nil { + err = fmt.Errorf("container still exists after delete: %d", task.ContainerID) + } + } + case TaskReinstall: + if lxc.HasSSHAuthOptions(task.Config) { + err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config) + } else { + err = reinstallByRuntime(task.ContainerID, task.TemplateID) + } + } + } + + auditUser := task.User + if auditUser == "" { + auditUser = "admin" + } + if err != nil { + config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error()) + q.finishTask(task, "failed", err) + return + } + if skipped { + config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "") + q.finishTask(task, "done", nil) + return + } + + config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "") + switch task.Type { + case TaskStart: + config.UpdateContainerStatus(task.ContainerID, "running") + clearPolicyBlockAfterAdminRecovery(task) + case TaskStop: + config.UpdateContainerStatus(task.ContainerID, "stopped") + case TaskRestart: + config.UpdateContainerStatus(task.ContainerID, "running") + clearPolicyBlockAfterAdminRecovery(task) + case TaskReinstall: + clearPolicyBlockAfterAdminRecovery(task) + } + q.finishTask(task, "done", nil) +} + func isSecurityStopTask(task *Task) bool { return task != nil && task.Type == TaskStop && task.User == "system:security" } @@ -503,7 +630,8 @@ func (q *TaskQueue) GetTasks() []*Task { result := make([]*Task, 0, len(q.tasks)) // Collect all task IDs, sort by creation time (extracted from ID number) for _, t := range q.tasks { - result = append(result, t) + copyTask := *t + result = append(result, ©Task) } // Stable sort by ID number (task-N where N is sequential) for i := 0; i < len(result); i++ { @@ -660,6 +788,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { if req.Containers[i].DiskGB < 1 { req.Containers[i].DiskGB = 5 } + if err := validateCreateStoragePool(&req.Containers[i]); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()}) + return + } 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 @@ -879,6 +1011,8 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) { // RestoreTasks restores task queue from config func RestoreTasks() { + globalQueue.mu.Lock() + defer globalQueue.mu.Unlock() for _, st := range config.AppConfig.Tasks { if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown { continue @@ -908,6 +1042,8 @@ func RestoreTasks() { ContainerName: containerName, Status: st.Status, Error: st.Error, + Stage: "queued", + StageDetail: "排队等待", CreatedAt: st.CreatedAt, TemplateID: st.TemplateID, Config: cfg, @@ -937,3 +1073,17 @@ func parseIDNum(id string) int { } return num } + +func validateCreateStoragePool(cfg *lxc.ContainerConfig) error { + required := config.StorageContentLXC + if cfg.Virtualization == config.VirtualizationKVM { + required = config.StorageContentKVM + } + requiredBytes := int64(cfg.DiskGB) * 1024 * 1024 * 1024 + pool, err := config.SelectStoragePoolForContent(required, cfg.StoragePoolID, requiredBytes) + if err != nil { + return err + } + cfg.StoragePoolID = pool.ID + return nil +} diff --git a/backend/internal/api/taskqueue_test.go b/backend/internal/api/taskqueue_test.go new file mode 100644 index 0000000..eeb2039 --- /dev/null +++ b/backend/internal/api/taskqueue_test.go @@ -0,0 +1,56 @@ +package api + +import ( + "testing" + + "clicd/internal/config" + "clicd/internal/lxc" +) + +func TestRunnableTaskIndexSkipsActiveContainer(t *testing.T) { + queue := []*Task{ + {ID: "task-1", Type: TaskStop, ContainerID: 1, ContainerName: "alpha"}, + {ID: "task-2", Type: TaskStart, ContainerID: 1, ContainerName: "alpha"}, + {ID: "task-3", Type: TaskStart, ContainerID: 2, ContainerName: "beta"}, + } + active := map[string]bool{taskConcurrencyKey(queue[0]): true} + + if got := runnableTaskIndex(queue[1:], active); got != 1 { + t.Fatalf("runnableTaskIndex() = %d, want 1 for the other container", got) + } +} + +func TestTaskConcurrencyKeyUsesContainerName(t *testing.T) { + create := &Task{ID: "task-1", Type: TaskCreate, Config: lxcConfigWithName("Example")} + operation := &Task{ID: "task-2", Type: TaskDelete, ContainerID: 9, ContainerName: "example"} + if taskConcurrencyKey(create) != taskConcurrencyKey(operation) { + t.Fatalf("same container received different concurrency keys: %q and %q", taskConcurrencyKey(create), taskConcurrencyKey(operation)) + } +} + +func TestTaskQueueSetConcurrencyNormalizesAndReports(t *testing.T) { + q := newTaskQueue(config.DefaultTaskConcurrency) + q.SetConcurrency(config.MaxTaskConcurrency + 10) + if got := q.Settings().Concurrency; got != config.MaxTaskConcurrency { + t.Fatalf("concurrency = %d, want %d", got, config.MaxTaskConcurrency) + } + q.SetConcurrency(0) + if got := q.Settings().Concurrency; got != config.DefaultTaskConcurrency { + t.Fatalf("concurrency = %d, want default %d", got, config.DefaultTaskConcurrency) + } +} + +func TestTaskQueueUpdateTaskStage(t *testing.T) { + q := newTaskQueue(config.DefaultTaskConcurrency) + task := &Task{ID: "task-1", Type: TaskCreate, Status: "running"} + + q.updateTaskStage(task, "rootfs", "下载模板并创建基础文件系统") + + if task.Stage != "rootfs" || task.StageDetail != "下载模板并创建基础文件系统" { + t.Fatalf("unexpected task stage: %q %q", task.Stage, task.StageDetail) + } +} + +func lxcConfigWithName(name string) lxc.ContainerConfig { + return lxc.ContainerConfig{Name: name} +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3ac21e4..9a276d2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,9 +5,12 @@ import ( "encoding/hex" "fmt" "os" + "os/exec" "path/filepath" + "sort" "strconv" "strings" + "sync" "time" "golang.org/x/crypto/bcrypt" @@ -109,6 +112,8 @@ type Container struct { LXCName string `json:"lxc_name,omitempty"` KVMName string `json:"kvm_name,omitempty"` DiskImage string `json:"disk_image,omitempty"` + StoragePoolID string `json:"storage_pool_id,omitempty"` + StoragePath string `json:"storage_path,omitempty"` MACAddress string `json:"mac_address,omitempty"` Template string `json:"template"` VCPU float64 `json:"vcpu"` @@ -202,6 +207,308 @@ func (c *Container) UsesLANIPv4() bool { return c.UsesLANDHCP() || c.UsesLANStaticIPv4() } +func normalizeStoragePools() bool { + if AppConfig == nil { + return false + } + changed := false + result := make([]StoragePool, 0, len(AppConfig.StoragePools)) + seen := map[string]bool{} + defaultSeen := map[string]bool{} + for _, pool := range AppConfig.StoragePools { + pool.ID = strings.TrimSpace(pool.ID) + pool.Name = strings.TrimSpace(pool.Name) + pool.Path = filepath.Clean(strings.TrimSpace(pool.Path)) + pool.MountPoint = filepath.Clean(strings.TrimSpace(pool.MountPoint)) + if pool.MountPoint == "." { + pool.MountPoint = "" + } + if pool.ID == "" { + pool.ID = storagePoolIDFromName(pool.Name, pool.Path) + changed = true + } + if pool.Name == "" { + pool.Name = pool.ID + changed = true + } + if pool.Path == "." || !filepath.IsAbs(pool.Path) || seen[pool.ID] { + changed = true + continue + } + seen[pool.ID] = true + pool.ContentTypes = normalizeStorageContentTypes(pool.ContentTypes) + pool.DefaultContents = normalizeStorageContentTypes(pool.DefaultContents) + allowed := map[string]bool{} + for _, content := range pool.ContentTypes { + allowed[content] = true + } + defaults := make([]string, 0, len(pool.DefaultContents)) + for _, content := range pool.DefaultContents { + if !allowed[content] || defaultSeen[content] { + changed = true + continue + } + defaultSeen[content] = true + defaults = append(defaults, content) + } + pool.DefaultContents = defaults + if pool.ContentTypes == nil { + pool.ContentTypes = []string{} + } + result = append(result, pool) + } + if len(result) != len(AppConfig.StoragePools) { + changed = true + } + AppConfig.StoragePools = result + return changed +} + +func storagePoolIDFromName(name, path string) string { + base := strings.ToLower(strings.TrimSpace(name)) + if base == "" { + base = filepath.Base(filepath.Clean(path)) + } + replacer := strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-") + base = replacer.Replace(base) + base = strings.Trim(base, "-") + if base == "" { + base = "storage" + } + return base +} + +func normalizeStorageContentTypes(values []string) []string { + if len(values) == 0 { + return nil + } + valid := map[string]bool{ + StorageContentLXC: true, + StorageContentKVM: true, + StorageContentImages: true, + StorageContentSnapshots: true, + StorageContentBackups: true, + } + seen := map[string]bool{} + result := []string{} + for _, value := range values { + next := strings.ToLower(strings.TrimSpace(value)) + if !valid[next] || seen[next] { + continue + } + seen[next] = true + result = append(result, next) + } + return result +} + +func StoragePoolsForContent(content string) []StoragePool { + if AppConfig == nil { + return nil + } + content = strings.ToLower(strings.TrimSpace(content)) + result := []StoragePool{} + for _, pool := range AppConfig.StoragePools { + if !pool.Enabled || !storagePoolAllows(pool, content) { + continue + } + result = append(result, pool) + } + return result +} + +func StoragePoolByID(id string) *StoragePool { + if AppConfig == nil { + return nil + } + id = strings.TrimSpace(id) + for i := range AppConfig.StoragePools { + if AppConfig.StoragePools[i].ID == id { + return &AppConfig.StoragePools[i] + } + } + return nil +} + +func StoragePoolAllowsContent(pool StoragePool, content string) bool { + return storagePoolAllows(pool, strings.ToLower(strings.TrimSpace(content))) +} + +func StoragePathForContent(content, fallback string) string { + if pool := DefaultStoragePoolForContent(content); pool != nil { + return pool.Path + } + return fallback +} + +// PreferredStoragePoolForContent returns the configured default without doing +// filesystem probes. Use SelectStoragePoolForContent for new writes. +func PreferredStoragePoolForContent(content string) *StoragePool { + if AppConfig == nil { + return nil + } + content = strings.ToLower(strings.TrimSpace(content)) + for i := range AppConfig.StoragePools { + pool := &AppConfig.StoragePools[i] + if !pool.Enabled || !storagePoolAllows(*pool, content) { + continue + } + for _, item := range pool.DefaultContents { + if item == content { + return pool + } + } + } + for i := range AppConfig.StoragePools { + pool := &AppConfig.StoragePools[i] + if pool.Enabled && storagePoolAllows(*pool, content) { + return pool + } + } + return nil +} + +func DefaultStoragePoolForContent(content string) *StoragePool { + pool, _ := SelectStoragePoolForContent(content, "", 0) + return pool +} + +const storagePoolFreeReserveBytes int64 = 256 * 1024 * 1024 + +type storagePoolCandidate struct { + pool *StoragePool + freeBytes int64 + isDefault bool +} + +// SelectStoragePoolForContent picks a writable mounted pool. The requested or +// configured default pool is preferred while it has enough space; remaining +// pools are tried by available space from largest to smallest. +func SelectStoragePoolForContent(content, requestedPoolID string, requiredBytes int64) (*StoragePool, error) { + if AppConfig == nil { + return nil, fmt.Errorf("storage configuration is not loaded") + } + content = strings.ToLower(strings.TrimSpace(content)) + requestedPoolID = strings.TrimSpace(requestedPoolID) + if requiredBytes < 0 { + requiredBytes = 0 + } + requiredFree := requiredBytes + storagePoolFreeReserveBytes + candidates := make([]storagePoolCandidate, 0, len(AppConfig.StoragePools)) + configured := 0 + for i := range AppConfig.StoragePools { + pool := &AppConfig.StoragePools[i] + if !pool.Enabled || !storagePoolAllows(*pool, content) { + continue + } + configured++ + freeBytes, available := probeStoragePoolFreeBytes(*pool) + if !available { + continue + } + candidate := storagePoolCandidate{pool: pool, freeBytes: freeBytes} + for _, item := range pool.DefaultContents { + if item == content { + candidate.isDefault = true + break + } + } + candidates = append(candidates, candidate) + } + if configured == 0 { + return nil, fmt.Errorf("no storage disk is enabled for %s", storageContentLabel(content)) + } + if len(candidates) == 0 { + return nil, fmt.Errorf("all storage disks enabled for %s are unavailable or unmounted", storageContentLabel(content)) + } + + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].freeBytes > candidates[j].freeBytes + }) + preferred := func(match func(storagePoolCandidate) bool) *StoragePool { + for _, candidate := range candidates { + if match(candidate) && candidate.freeBytes >= requiredFree { + return candidate.pool + } + } + return nil + } + if requestedPoolID != "" { + if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.pool.ID == requestedPoolID }); pool != nil { + return pool, nil + } + } + if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.isDefault }); pool != nil { + return pool, nil + } + if pool := preferred(func(storagePoolCandidate) bool { return true }); pool != nil { + return pool, nil + } + return nil, fmt.Errorf("storage disks enabled for %s do not have enough free space", storageContentLabel(content)) +} + +var probeStoragePoolFreeBytes = storagePoolFreeBytes + +func storagePoolFreeBytes(pool StoragePool) (int64, bool) { + if strings.TrimSpace(pool.Path) == "" { + return 0, false + } + if _, err := os.Stat(pool.Path); err != nil { + if !os.IsNotExist(err) || filepath.Clean(pool.MountPoint) != string(os.PathSeparator) { + return 0, false + } + if err := os.MkdirAll(pool.Path, 0755); err != nil { + return 0, false + } + } + if mountPoint := strings.TrimSpace(pool.MountPoint); mountPoint != "" { + out, err := exec.Command("findmnt", "-n", "-o", "TARGET", "--target", pool.Path).Output() + if err != nil || filepath.Clean(strings.TrimSpace(string(out))) != filepath.Clean(mountPoint) { + return 0, false + } + } + out, err := exec.Command("df", "-B1", "-P", pool.Path).Output() + if err != nil { + return 0, false + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 2 { + return 0, false + } + fields := strings.Fields(lines[len(lines)-1]) + if len(fields) < 4 { + return 0, false + } + freeBytes, err := strconv.ParseInt(fields[3], 10, 64) + return freeBytes, err == nil +} + +func storageContentLabel(content string) string { + switch content { + case StorageContentLXC: + return "LXC containers" + case StorageContentKVM: + return "KVM disks" + case StorageContentImages: + return "image cache" + case StorageContentSnapshots: + return "snapshots" + case StorageContentBackups: + return "backups" + default: + return content + } +} + +func storagePoolAllows(pool StoragePool, content string) bool { + for _, item := range pool.ContentTypes { + if item == content { + return true + } + } + return false +} + func (c *Container) NormalizeNetworkAssignments() bool { changed := false lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode)) @@ -422,6 +729,43 @@ type SSLConfig struct { LastError string `json:"last_error,omitempty"` } +const ( + StorageContentLXC = "lxc" + StorageContentKVM = "kvm" + StorageContentImages = "images" + StorageContentSnapshots = "snapshots" + StorageContentBackups = "backups" +) + +type StoragePool struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + MountPoint string `json:"mount_point,omitempty"` + ContentTypes []string `json:"content_types"` + DefaultContents []string `json:"default_contents,omitempty"` + Enabled bool `json:"enabled"` +} + +func defaultPrimaryStoragePool() StoragePool { + contents := []string{ + StorageContentLXC, + StorageContentKVM, + StorageContentImages, + StorageContentSnapshots, + StorageContentBackups, + } + return StoragePool{ + ID: "disk-root", + Name: "system (/)", + Path: "/var/lib/clicd", + MountPoint: "/", + ContentTypes: append([]string(nil), contents...), + DefaultContents: append([]string(nil), contents...), + Enabled: true, + } +} + // ClicdConfig is the main configuration structure type ClicdConfig struct { AdminUser string `json:"admin_user"` @@ -447,16 +791,24 @@ type ClicdConfig struct { PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"` WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"` SecurityAutoShutdown bool `json:"security_auto_shutdown"` + TaskConcurrency int `json:"task_concurrency"` Language string `json:"language"` SSL SSLConfig `json:"ssl"` SSLCertificates map[string]SSLConfig `json:"ssl_certificates"` + StoragePools []StoragePool `json:"storage_pools"` } var configPath string var AppConfig *ClicdConfig +var allocationMu sync.Mutex const DefaultSnapshotLimit = 3 +const ( + DefaultTaskConcurrency = 2 + MaxTaskConcurrency = 16 +) + const ( DefaultNATPortStart = 20000 DefaultNATPortEnd = 65535 @@ -588,6 +940,8 @@ func InitConfig() (*ClicdConfig, error) { PublicIPv4Pool: []PublicIPv4Assignment{}, PublicIPv6Prefixes: []PublicIPv6Prefix{}, WebSSHAllowedOrigins: []string{}, + TaskConcurrency: DefaultTaskConcurrency, + StoragePools: []StoragePool{defaultPrimaryStoragePool()}, } if err := SaveConfig(); err != nil { @@ -629,6 +983,10 @@ func normalizeConfigDefaults(dataDir string) bool { AppConfig.NextContainerID = 1 changed = true } + if normalized := NormalizeTaskConcurrency(AppConfig.TaskConcurrency); AppConfig.TaskConcurrency != normalized { + AppConfig.TaskConcurrency = normalized + changed = true + } if AppConfig.DataDir == "" { AppConfig.DataDir = dataDir changed = true @@ -656,6 +1014,13 @@ func normalizeConfigDefaults(dataDir string) bool { AppConfig.WebSSHAllowedOrigins = normalized changed = true } + if len(AppConfig.StoragePools) == 0 { + AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()} + changed = true + } + if normalizeStoragePools() { + changed = true + } if AppConfig.SubUsers == nil { AppConfig.SubUsers = make([]SubUser, 0) changed = true @@ -701,6 +1066,16 @@ func normalizeConfigDefaults(dataDir string) bool { return changed } +func NormalizeTaskConcurrency(value int) int { + if value <= 0 { + return DefaultTaskConcurrency + } + if value > MaxTaskConcurrency { + return MaxTaskConcurrency + } + return value +} + func NormalizeLanguage(language string) string { switch strings.ToLower(strings.TrimSpace(language)) { case "en", "en-us", "en_us", "english": @@ -1047,6 +1422,8 @@ func SaveConfig() error { // AddContainer adds a container to the config func AddContainer(c Container) { + allocationMu.Lock() + defer allocationMu.Unlock() if c.UUID == "" { c.UUID = NewContainerUUID() } @@ -1058,6 +1435,8 @@ func AddContainer(c Container) { // AllocateContainerID allocates a new container ID func AllocateContainerID() int { + allocationMu.Lock() + defer allocationMu.Unlock() id := AppConfig.NextContainerID AppConfig.NextContainerID++ SaveConfig() @@ -1334,6 +1713,8 @@ func normalizeNATPortRangeDefaults() bool { // AllocateSSHPort allocates a new SSH port, skipping ports already used by any container func AllocateSSHPort() (int, error) { + allocationMu.Lock() + defer allocationMu.Unlock() used := collectAllHostPorts() start, end := NATPortRange() port := AppConfig.NextSSHPort diff --git a/backend/internal/config/storage_test.go b/backend/internal/config/storage_test.go new file mode 100644 index 0000000..d9d35dc --- /dev/null +++ b/backend/internal/config/storage_test.go @@ -0,0 +1,90 @@ +package config + +import "testing" + +func TestSelectStoragePoolForContent(t *testing.T) { + previousConfig := AppConfig + previousProbe := probeStoragePoolFreeBytes + t.Cleanup(func() { + AppConfig = previousConfig + probeStoragePoolFreeBytes = previousProbe + }) + + AppConfig = &ClicdConfig{StoragePools: []StoragePool{ + { + ID: "primary", + Path: "/primary", + ContentTypes: []string{StorageContentLXC}, + DefaultContents: []string{StorageContentLXC}, + Enabled: true, + }, + { + ID: "large", + Path: "/large", + ContentTypes: []string{StorageContentLXC}, + Enabled: true, + }, + { + ID: "small", + Path: "/small", + ContentTypes: []string{StorageContentLXC}, + Enabled: true, + }, + }} + + free := map[string]int64{ + "primary": 20 * 1024 * 1024 * 1024, + "large": 50 * 1024 * 1024 * 1024, + "small": 10 * 1024 * 1024 * 1024, + } + probeStoragePoolFreeBytes = func(pool StoragePool) (int64, bool) { + value, ok := free[pool.ID] + return value, ok + } + + pool, err := SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024) + if err != nil { + t.Fatal(err) + } + if pool.ID != "primary" { + t.Fatalf("selected %q, want configured default primary", pool.ID) + } + + free["primary"] = 128 * 1024 * 1024 + pool, err = SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024) + if err != nil { + t.Fatal(err) + } + if pool.ID != "large" { + t.Fatalf("selected %q, want largest fallback pool", pool.ID) + } + + pool, err = SelectStoragePoolForContent(StorageContentLXC, "small", 5*1024*1024*1024) + if err != nil { + t.Fatal(err) + } + if pool.ID != "small" { + t.Fatalf("selected %q, want requested pool", pool.ID) + } +} + +func TestSelectStoragePoolRequiresEnabledContent(t *testing.T) { + previousConfig := AppConfig + previousProbe := probeStoragePoolFreeBytes + t.Cleanup(func() { + AppConfig = previousConfig + probeStoragePoolFreeBytes = previousProbe + }) + + AppConfig = &ClicdConfig{StoragePools: []StoragePool{{ + ID: "primary", + Path: "/primary", + ContentTypes: []string{StorageContentLXC}, + Enabled: true, + }}} + probeStoragePoolFreeBytes = func(StoragePool) (int64, bool) { return 100 * 1024 * 1024 * 1024, true } + + if _, err := SelectStoragePoolForContent(StorageContentSnapshots, "", 0); err == nil { + t.Fatal("expected snapshots selection to fail when no pool enables snapshots") + } +} diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index 5d8c2d4..6d4a459 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -23,6 +23,7 @@ type savedTaskConfig struct { Name string `json:"name"` Virtualization string `json:"virtualization,omitempty"` TemplateID string `json:"template_id"` + StoragePoolID string `json:"storage_pool_id,omitempty"` VCPU float64 `json:"vcpu"` CPUPercent int `json:"cpu_percent"` RAMMB int `json:"ram_mb"` @@ -190,6 +191,8 @@ func ensureSchema() error { lxc_name TEXT, kvm_name TEXT, disk_image TEXT, + storage_pool_id TEXT, + storage_path TEXT, mac_address TEXT, template TEXT, vcpu REAL, @@ -461,6 +464,8 @@ func ensureSchemaMigrations() error { {"containers", "allowed_image_ids", "TEXT"}, {"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "restore_on_host_boot", "INTEGER NOT NULL DEFAULT 0"}, + {"containers", "storage_pool_id", "TEXT"}, + {"containers", "storage_path", "TEXT"}, {"containers", "lan_ipv4_mode", "TEXT"}, {"containers", "lan_interface", "TEXT"}, {"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"}, @@ -515,6 +520,11 @@ func ensureSchemaMigrations() error { lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil { return err } + if _, err := db.Exec(`UPDATE containers + SET storage_pool_id = COALESCE(storage_pool_id, ''), + storage_path = COALESCE(storage_path, '')`); err != nil { + return err + } if _, err := db.Exec(`UPDATE tasks SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''), cfg_lan_interface = COALESCE(cfg_lan_interface, ''), @@ -585,6 +595,7 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) { NATPortEnd: atoi(meta["nat_port_end"]), SetupComplete: atob(meta["setup_complete"]), SecurityAutoShutdown: atob(meta["security_auto_shutdown"]), + TaskConcurrency: atoi(meta["task_concurrency"]), Language: meta["language"], } if raw := strings.TrimSpace(meta["ssl"]); raw != "" { @@ -602,6 +613,9 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) { if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" { _ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins) } + if raw := strings.TrimSpace(meta["storage_pools"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &cfg.StoragePools) + } if cfg.Containers, err = loadContainers(); err != nil { return nil, false, err @@ -701,6 +715,7 @@ func saveMeta(tx *sql.Tx) error { publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool) publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes) webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins) + storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools) values := map[string]string{ "admin_user": AppConfig.AdminUser, "admin_pass_hash": AppConfig.AdminPassHash, @@ -714,12 +729,14 @@ func saveMeta(tx *sql.Tx) error { "nat_port_end": strconv.Itoa(AppConfig.NATPortEnd), "setup_complete": btoa(AppConfig.SetupComplete), "security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown), + "task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency), "language": NormalizeLanguage(AppConfig.Language), "ssl": string(sslJSON), "ssl_certificates": string(sslCertificatesJSON), "public_ipv4_pool": string(publicIPv4PoolJSON), "public_ipv6_prefixes": string(publicIPv6PrefixesJSON), "webssh_allowed_origins": string(webSSHAllowedOriginsJSON), + "storage_pools": string(storagePoolsJSON), "schema_version": "1", "updated_at": time.Now().Format("2006-01-02 15:04:05"), } @@ -736,7 +753,7 @@ func saveContainers(tx *sql.Tx) error { NormalizeContainerResourceAliases(&c) allowedImageIDs := encodeStringSlice(c.AllowedImageIDs) if _, err := tx.Exec(`INSERT INTO containers ( - id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template, + id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template, vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb, traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, @@ -748,8 +765,8 @@ func saveContainers(tx *sql.Tx) error { snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, policy_blocked, policy_blocked_reason, policy_blocked_at, firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.StoragePoolID, c.StoragePath, c.MACAddress, c.Template, c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB, c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, @@ -957,7 +974,7 @@ func saveSnapshots(tx *sql.Tx) error { func loadContainers() ([]Container, error) { rows, err := db.Query(`SELECT - id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template, + id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template, vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb, traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, @@ -981,11 +998,12 @@ func loadContainers() ([]Container, error) { var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured, restoreOnHostBoot int var firewallDefaultAction string var firewallRulesJSON, allowedImageIDs sql.NullString + var storagePoolID, storagePath sql.NullString var lanIPv4Mode, lanInterface sql.NullString var lanIPv4Address, lanIPv4Gateway sql.NullString var lanIPv4PrefixLen sql.NullInt64 if err := rows.Scan( - &c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template, + &c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &storagePoolID, &storagePath, &c.MACAddress, &c.Template, &c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB, &c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, @@ -1000,6 +1018,8 @@ func loadContainers() ([]Container, error) { ); err != nil { return nil, err } + c.StoragePoolID = storagePoolID.String + c.StoragePath = storagePath.String c.LANIPv4Mode = lanIPv4Mode.String c.LANInterface = lanInterface.String c.LANIPv4Address = lanIPv4Address.String diff --git a/backend/internal/config/store_sqlite_test.go b/backend/internal/config/store_sqlite_test.go index a3a77de..6c11305 100644 --- a/backend/internal/config/store_sqlite_test.go +++ b/backend/internal/config/store_sqlite_test.go @@ -93,11 +93,15 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) { if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) { t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks) } + if cfg.TaskConcurrency != DefaultTaskConcurrency { + t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency) + } if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil { t.Fatalf("sqlite database was not created: %v", err) } cfg.Containers[0].Status = "stopped" + cfg.TaskConcurrency = 6 if err := SaveConfig(); err != nil { t.Fatal(err) } @@ -111,6 +115,9 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) { if got := cfg.Containers[0].Status; got != "stopped" { t.Fatalf("expected sqlite value to win after migration, got %q", got) } + if got := cfg.TaskConcurrency; got != 6 { + t.Fatalf("persisted task concurrency = %d, want 6", got) + } } func resetConfigStoreForTest(t *testing.T) { diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 4c52542..ee77faa 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -95,6 +95,9 @@ var ( ) func BaseDir() string { + if pool := config.PreferredStoragePoolForContent(config.StorageContentKVM); pool != nil { + return filepath.Join(pool.Path, "kvm") + } return "/var/lib/clicd/kvm" } @@ -102,11 +105,30 @@ func NewManager() *Manager { return &Manager{BasePath: BaseDir()} } +func NewManagerForStoragePool(poolID string) *Manager { + if pool := config.StoragePoolByID(poolID); pool != nil && pool.Enabled { + for _, content := range pool.ContentTypes { + if content == config.StorageContentKVM { + return &Manager{BasePath: filepath.Join(pool.Path, "kvm")} + } + } + } + return NewManager() +} + func (m *Manager) instancesDir() string { return filepath.Join(m.BasePath, "instances") } func (m *Manager) instanceDir(name string) string { + if config.AppConfig != nil { + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if c.IsKVM() && c.VirshName() == name && strings.TrimSpace(c.DiskImage) != "" { + return filepath.Dir(c.DiskImage) + } + } + } return filepath.Join(m.instancesDir(), name) } @@ -135,10 +157,19 @@ func DownloadImage(image Image) error { } func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error { - if err := os.MkdirAll(CacheDir(), 0755); err != nil { + pool, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024) + if err != nil { return err } - target := ImagePath(image.ID) + cacheDir := filepath.Join(pool.Path, "images", "kvm") + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return err + } + ext := ".qcow2" + if image.Distro == "windows" { + ext = ".iso" + } + target := filepath.Join(cacheDir, image.ID+ext) if ok, _ := ImageDownloadedInfo(image.ID); ok { return nil } @@ -348,6 +379,7 @@ func normalizeQCOW2(ctx context.Context, src, target string) error { func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error { cfg.NormalizeResourceAliases() + cfg.ReportProgress("preparing", "检查 KVM 镜像与创建参数") image := FindImage(cfg.TemplateID) if image == nil { return fmt.Errorf("KVM image not found: %s", cfg.TemplateID) @@ -367,6 +399,22 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error { 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 IsWindowsImage(image.ID) && cfg.DiskGB < 30 { + cfg.DiskGB = 30 + } else if image.Desktop != "" && cfg.DiskGB < 20 { + cfg.DiskGB = 20 + } + cfg.ReportProgress("storage", "选择虚拟机存储磁盘") + pool, err := config.SelectStoragePoolForContent( + config.StorageContentKVM, + cfg.StoragePoolID, + int64(cfg.DiskGB)*1024*1024*1024, + ) + if err != nil { + return err + } + cfg.StoragePoolID = pool.ID + m = NewManagerForStoragePool(pool.ID) if cfg.WantsNAT() && cfg.PortMappingCount < 2 { cfg.PortMappingCount = 2 } else if !cfg.WantsNAT() { @@ -424,6 +472,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig sshPublicKey = sshAccess.PublicKey sshAuthMode = sshAccess.Mode } + cfg.ReportProgress("addresses", "分配 IPv4 与 IPv6 地址") publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4) if err != nil { return nil, err @@ -451,6 +500,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig if cfg.DiskGB < 30 { cfg.DiskGB = 30 } + cfg.ReportProgress("disk", "创建 Windows 虚拟磁盘") if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil { return nil, err } @@ -459,6 +509,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig } winAdminPassword = generateWindowsPassword() unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso") + cfg.ReportProgress("cloud_init", "生成 Windows 自动应答配置") if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil { return nil, err } @@ -472,9 +523,11 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig cfg.DiskGB = 20 } } + cfg.ReportProgress("disk", "创建 KVM 系统磁盘") if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil { return nil, err } + cfg.ReportProgress("cloud_init", "生成 cloud-init 初始化配置") if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil { return nil, err } @@ -484,6 +537,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { return nil, err } + cfg.ReportProgress("define", "注册 KVM 虚拟机") 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)) @@ -492,6 +546,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig sshPort := 0 portMappings := []config.PortMapping{} if allocatePorts && cfg.WantsNAT() { + cfg.ReportProgress("nat", "分配并配置 NAT 端口") sshPort, err = config.AllocateSSHPort() if err != nil { return nil, err @@ -538,6 +593,12 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig if trafficMode == "" { trafficMode = "total" } + storagePoolID := cfg.StoragePoolID + if storagePoolID == "" { + if pool := config.DefaultStoragePoolForContent(config.StorageContentKVM); pool != nil { + storagePoolID = pool.ID + } + } container := &config.Container{ ID: id, UUID: config.NewContainerUUID(), @@ -545,6 +606,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig Virtualization: config.VirtualizationKVM, KVMName: vmName, DiskImage: diskPath, + StoragePoolID: storagePoolID, + StoragePath: m.instanceDir(vmName), MACAddress: mac, Template: cfg.TemplateID, VCPU: cfg.VCPU, @@ -580,6 +643,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig ExpiresAt: cfg.ExpiresAt, } container.NormalizeNetworkAssignments() + cfg.ReportProgress("metadata", "保存虚拟机配置") return container, nil } @@ -954,7 +1018,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error { return nil } -func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) { +func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) { kvmSnapshotMu.Lock() defer kvmSnapshotMu.Unlock() @@ -980,17 +1044,26 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat name := c.VirshName() instanceDir := m.instanceDir(name) - if err := safePathUnder(instanceDir, m.instancesDir()); err != nil { + if err := safePathUnder(instanceDir, filepath.Dir(instanceDir)); err != nil { return config.Snapshot{}, err } if _, err := os.Stat(instanceDir); err != nil { return config.Snapshot{}, fmt.Errorf("VM storage not found: %v", err) } + pool, err := config.SelectStoragePoolForContent( + config.StorageContentSnapshots, + firstString(storagePoolID), + dirSizeBytes(instanceDir), + ) + if err != nil { + return config.Snapshot{}, 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 { + baseDir := filepath.Join(pool.Path, "snapshots") + snapshotDir := filepath.Join(baseDir, "kvm", strconv.Itoa(id), snapshotID) + if err := safePathUnder(snapshotDir, baseDir); err != nil { return config.Snapshot{}, err } if err := os.MkdirAll(snapshotDir, 0700); err != nil { @@ -1043,7 +1116,7 @@ func (m *Manager) DeleteSnapshot(id string) error { func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error { if snapshot.Path != "" { - if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { + if err := safeSnapshotPath(snapshot.Path); err != nil { return err } if err := os.RemoveAll(snapshot.Path); err != nil { @@ -1065,7 +1138,7 @@ func (m *Manager) RestoreSnapshot(id string) error { if snapshot.Path == "" { return fmt.Errorf("snapshot path is empty") } - if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { + if err := safeSnapshotPath(snapshot.Path); err != nil { return err } if _, err := os.Stat(snapshot.Path); err != nil { @@ -1081,7 +1154,8 @@ func (m *Manager) RestoreSnapshot(id string) error { } name := c.VirshName() instanceDir := m.instanceDir(name) - if err := safePathUnder(instanceDir, m.instancesDir()); err != nil { + instanceParent := filepath.Dir(instanceDir) + if err := safePathUnder(instanceDir, instanceParent); err != nil { return err } @@ -1089,8 +1163,8 @@ func (m *Manager) RestoreSnapshot(id string) error { 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 { + backupDir := filepath.Join(instanceParent, fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano())) + if err := safePathUnder(backupDir, instanceParent); err != nil { return err } if err := os.Rename(instanceDir, backupDir); err != nil && !os.IsNotExist(err) { @@ -1110,6 +1184,7 @@ func (m *Manager) RestoreSnapshot(id string) error { return fmt.Errorf("virsh define failed after restore: %v, output: %s", err, string(output)) } c.DiskImage = filepath.Join(instanceDir, "disk.qcow2") + c.StoragePath = instanceDir c.Status = "stopped" c.IP = "" config.SaveConfig() @@ -1244,7 +1319,33 @@ func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) tim } func snapshotBaseDir() string { - return filepath.Join(config.AppConfig.DataDir, "snapshots") + return snapshotBaseDirForPool("") +} + +func snapshotBaseDirForPool(poolID string) string { + if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil { + return filepath.Join(pool.Path, "snapshots") + } + return "" +} + +func safeSnapshotPath(path string) error { + if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil { + return nil + } + for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) { + if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil { + return nil + } + } + return fmt.Errorf("unsafe snapshot path: %s", path) +} + +func firstString(values []string) string { + if len(values) == 0 { + return "" + } + return strings.TrimSpace(values[0]) } func copyTree(src string, dst string) error { diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go index 4558fa8..08296b3 100644 --- a/backend/internal/kvm/templates.go +++ b/backend/internal/kvm/templates.go @@ -1,8 +1,11 @@ package kvm import ( + "os" "path/filepath" "runtime" + + "clicd/internal/config" ) type Image struct { @@ -180,6 +183,9 @@ func FindImage(id string) *Image { } func CacheDir() string { + if pool := config.PreferredStoragePoolForContent(config.StorageContentImages); pool != nil { + return filepath.Join(pool.Path, "images", "kvm") + } return filepath.Join(BaseDir(), "images") } @@ -189,7 +195,18 @@ func ImagePath(id string) string { if img != nil && img.Distro == "windows" { ext = ".iso" } - return filepath.Join(CacheDir(), id+ext) + fileName := id + ext + for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) { + candidate := filepath.Join(pool.Path, "images", "kvm", fileName) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + legacy := filepath.Join("/var/lib/clicd/kvm/images", fileName) + if info, err := os.Stat(legacy); err == nil && !info.IsDir() { + return legacy + } + return filepath.Join(CacheDir(), fileName) } // IsWindowsImage returns true if the image distro is "windows". diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index b19ba7e..8ea8d01 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -18,6 +18,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" "clicd/internal/config" @@ -227,44 +228,53 @@ 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"` - RAMMB int `json:"ram_mb"` - DiskGB int `json:"disk_gb"` - NetworkBWMbps int `json:"network_bw_mbps"` - NetworkDownMbps int `json:"network_down_mbps"` - NetworkUpMbps int `json:"network_up_mbps"` - MonthlyTrafficGB int `json:"monthly_traffic_gb"` - TrafficMode string `json:"traffic_mode"` // "total" or "in_out" - TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited - TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited - IOSpeedMBps int `json:"io_speed_mbps"` - IOReadMBps int `json:"io_read_mbps"` - IOWriteMBps int `json:"io_write_mbps"` - ExtraPorts []int `json:"extra_ports"` - PortMappingCount int `json:"port_mapping_count"` - AssignNAT *bool `json:"assign_nat,omitempty"` - LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"` - LANInterface string `json:"lan_interface,omitempty"` - LANIPv4Address string `json:"lan_ipv4_address,omitempty"` - LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"` - LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"` - SnapshotLimit int `json:"snapshot_limit"` - AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` - ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` - AssignIPv4 bool `json:"assign_ipv4"` - IPv4Count int `json:"ipv4_count,omitempty"` - PublicIPv4s []string `json:"public_ipv4s,omitempty"` - AssignIPv6 bool `json:"assign_ipv6"` - IPv6Count int `json:"ipv6_count,omitempty"` - IPv6Addresses []string `json:"ipv6_addresses,omitempty"` - SSHAuthMode string `json:"ssh_auth_mode,omitempty"` - SSHPassword string `json:"ssh_password,omitempty"` - SSHPublicKey string `json:"ssh_public_key,omitempty"` - ExpiresAt string `json:"expires_at"` + Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` + TemplateID string `json:"template_id"` + StoragePoolID string `json:"storage_pool_id,omitempty"` + VCPU float64 `json:"vcpu"` + CPUPercent int `json:"cpu_percent"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + NetworkBWMbps int `json:"network_bw_mbps"` + NetworkDownMbps int `json:"network_down_mbps"` + NetworkUpMbps int `json:"network_up_mbps"` + MonthlyTrafficGB int `json:"monthly_traffic_gb"` + TrafficMode string `json:"traffic_mode"` // "total" or "in_out" + TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited + TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited + IOSpeedMBps int `json:"io_speed_mbps"` + IOReadMBps int `json:"io_read_mbps"` + IOWriteMBps int `json:"io_write_mbps"` + ExtraPorts []int `json:"extra_ports"` + PortMappingCount int `json:"port_mapping_count"` + AssignNAT *bool `json:"assign_nat,omitempty"` + LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"` + LANInterface string `json:"lan_interface,omitempty"` + LANIPv4Address string `json:"lan_ipv4_address,omitempty"` + LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"` + LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"` + SnapshotLimit int `json:"snapshot_limit"` + AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` + ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` + AssignIPv4 bool `json:"assign_ipv4"` + IPv4Count int `json:"ipv4_count,omitempty"` + PublicIPv4s []string `json:"public_ipv4s,omitempty"` + AssignIPv6 bool `json:"assign_ipv6"` + IPv6Count int `json:"ipv6_count,omitempty"` + IPv6Addresses []string `json:"ipv6_addresses,omitempty"` + SSHAuthMode string `json:"ssh_auth_mode,omitempty"` + SSHPassword string `json:"ssh_password,omitempty"` + SSHPublicKey string `json:"ssh_public_key,omitempty"` + ExpiresAt string `json:"expires_at"` + Progress func(stage, detail string) `json:"-"` +} + +// ReportProgress reports a best-effort creation phase to the task queue. +func (cfg ContainerConfig) ReportProgress(stage, detail string) { + if cfg.Progress != nil { + cfg.Progress(stage, detail) + } } func (cfg *ContainerConfig) NormalizeResourceAliases() { @@ -324,6 +334,7 @@ func (cfg ContainerConfig) WantsLANIPv4() bool { // CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally. func (m *Manager) CreateContainer(cfg ContainerConfig) error { cfg.NormalizeResourceAliases() + cfg.ReportProgress("preparing", "检查模板与创建参数") tmpl := FindTemplate(cfg.TemplateID) if tmpl == nil { return fmt.Errorf("template not found: %s", cfg.TemplateID) @@ -369,6 +380,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { fmt.Printf("Creating LXC container: %s (ID=%d, template: %s/%s/%s)\n", lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch) + cfg.ReportProgress("rootfs", "下载模板并创建基础文件系统") args := []string{"-n", lxcName, "-t", "download", "--", "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch} if tmpl.Variant != "" { @@ -380,10 +392,19 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output)) } + cfg.ReportProgress("storage", "复制容器数据到存储磁盘") + storagePoolID, storagePath, err := m.moveContainerToStoragePool(lxcName, cfg.StoragePoolID) + if err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + + cfg.ReportProgress("disk", "创建容量限制磁盘并复制 rootfs") if err := m.applyDiskLimit(lxcName, cfg.DiskGB); err != nil { _ = m.cleanupContainerStorage(lxcName) return err } + cfg.ReportProgress("resources", "配置 CPU、内存与网络限制") if cfg.WantsLANIPv4() { iface, err := m.applyLANIPv4Config(lxcName, cfg) if err != nil { @@ -399,6 +420,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { return err } + cfg.ReportProgress("addresses", "分配 IPv4、IPv6 与 NAT 端口") publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4) if err != nil { _ = m.cleanupContainerStorage(lxcName) @@ -472,6 +494,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { Name: cfg.Name, Virtualization: config.VirtualizationLXC, LXCName: lxcName, + StoragePoolID: storagePoolID, + StoragePath: storagePath, Template: cfg.TemplateID, VCPU: cfg.VCPU, RAMMB: cfg.RAMMB, @@ -509,16 +533,19 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { ExpiresAt: cfg.ExpiresAt, } container.NormalizeNetworkAssignments() + cfg.ReportProgress("metadata", "保存容器配置") config.AddContainer(container) // Pre-configure network and SSH in the rootfs before first boot. rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") + cfg.ReportProgress("network", "写入容器网络配置") m.preconfigureNetwork(rootfsPath, cfg) if len(ipv6Assignments) > 0 { if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil { fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) } } + cfg.ReportProgress("ssh", "安装并配置 SSH 服务") if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil { fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) } @@ -530,6 +557,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { } } + cfg.ReportProgress("permissions", "转换非特权容器文件权限") if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { _ = m.cleanupContainerStorage(lxcName) config.RemoveContainer(id) @@ -538,6 +566,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { // Set root password AFTER shiftRootfsForUnprivileged, // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. + cfg.ReportProgress("credentials", "设置容器登录凭据") if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil { fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err) } @@ -1128,6 +1157,78 @@ func (m *Manager) applyLoopbackDiskLimit(lxcName string, diskGB int) error { return nil } +func (m *Manager) moveContainerToStoragePool(lxcName string, requestedPoolID string) (string, string, error) { + sourceDir := filepath.Join(m.LxcPath, lxcName) + requiredBytes := dirSizeBytes(sourceDir) + pool, err := config.SelectStoragePoolForContent(config.StorageContentLXC, requestedPoolID, requiredBytes) + if err != nil { + return "", "", err + } + targetRoot := filepath.Join(pool.Path, "lxc") + targetDir := filepath.Join(targetRoot, lxcName) + sourceAbs, err := filepath.Abs(sourceDir) + if err != nil { + return "", "", err + } + targetAbs, err := filepath.Abs(targetDir) + if err != nil { + return "", "", err + } + if sourceAbs == targetAbs { + return pool.ID, targetAbs, nil + } + if err := os.MkdirAll(targetRoot, 0755); err != nil { + return "", "", err + } + if _, err := os.Lstat(targetDir); err == nil { + return "", "", fmt.Errorf("target storage directory already exists: %s", targetDir) + } + if err := moveLXCStorageDirectory(sourceDir, targetDir); err != nil { + return "", "", err + } + return pool.ID, targetAbs, nil +} + +func moveLXCStorageDirectory(sourceDir, targetDir string) error { + if err := os.Rename(sourceDir, targetDir); err == nil { + if err := os.Symlink(targetDir, sourceDir); err != nil { + _ = os.Rename(targetDir, sourceDir) + return fmt.Errorf("failed to create LXC storage symlink: %v", err) + } + return nil + } else if !errors.Is(err, syscall.EXDEV) { + return fmt.Errorf("failed to move LXC container to storage pool: %v", err) + } + + if err := copyTree(sourceDir, targetDir); err != nil { + _ = os.RemoveAll(targetDir) + return fmt.Errorf("failed to copy LXC container to storage pool: %v", err) + } + backupDir := sourceDir + fmt.Sprintf(".storage-move-%d", time.Now().UnixNano()) + if err := os.Rename(sourceDir, backupDir); err != nil { + _ = os.RemoveAll(targetDir) + return fmt.Errorf("failed to finalize LXC storage move: %v", err) + } + if err := os.Symlink(targetDir, sourceDir); err != nil { + _ = os.Rename(backupDir, sourceDir) + _ = os.RemoveAll(targetDir) + return fmt.Errorf("failed to create LXC storage symlink: %v", err) + } + if err := os.RemoveAll(backupDir); err != nil { + fmt.Printf("Warning: LXC storage moved but source cleanup failed: %v\n", err) + } + return nil +} + +func storagePoolAllowsContent(pool config.StoragePool, content string) bool { + for _, item := range pool.ContentTypes { + if item == content { + return true + } + } + return false +} + func (m *Manager) ensureDiskImageMounted(lxcName string) error { containerDir := filepath.Join(m.LxcPath, lxcName) rootfsPath := filepath.Join(containerDir, "rootfs") @@ -1183,15 +1284,30 @@ func diskImageMounted(lxcName, rootfsPath string) bool { if err != nil { return false } - targetAbs, err := filepath.Abs(strings.TrimSpace(target)) - if err != nil { - return false + return sameFilesystemPath(strings.TrimSpace(target), rootfsPath) +} + +func sameFilesystemPath(left, right string) bool { + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + if leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) { + return true } - rootfsAbs, err := filepath.Abs(rootfsPath) - if err != nil { - return false + + canonical := func(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(absolute) + if err == nil { + absolute = resolved + } + return filepath.Clean(absolute), nil } - return targetAbs == rootfsAbs + leftPath, leftErr := canonical(left) + rightPath, rightErr := canonical(right) + return leftErr == nil && rightErr == nil && leftPath == rightPath } func applyXFSProjectQuota(rootfsPath, lxcName string, diskGB int) error { @@ -2745,6 +2861,17 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error { if _, err := os.Stat(cleanPath); os.IsNotExist(err) { return nil } + var linkedTarget string + if info, err := os.Lstat(cleanPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + if target, err := os.Readlink(cleanPath); err == nil { + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(cleanPath), target) + } + if abs, err := filepath.Abs(target); err == nil && lxcStorageTargetAllowed(abs) { + linkedTarget = abs + } + } + } exec.Command("lxc-stop", "-n", lxcName, "-k").Run() exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() m.detachContainerMounts(cleanPath) @@ -2756,9 +2883,25 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error { if err := os.RemoveAll(cleanPath); err != nil { return fmt.Errorf("failed to remove container directory %s: %v", cleanPath, err) } + if linkedTarget != "" { + m.detachContainerMounts(linkedTarget) + m.detachContainerLoopDevices(linkedTarget) + _ = os.RemoveAll(linkedTarget) + } return nil } +func lxcStorageTargetAllowed(path string) bool { + for _, pool := range config.StoragePoolsForContent(config.StorageContentLXC) { + root := filepath.Join(pool.Path, "lxc") + rel, err := filepath.Rel(root, path) + if err == nil && rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + return true + } + } + return false +} + func (m *Manager) detachContainerMounts(containerDir string) { out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output() if err != nil { diff --git a/backend/internal/lxc/lxc_test.go b/backend/internal/lxc/lxc_test.go index 7e26935..ee2eb61 100644 --- a/backend/internal/lxc/lxc_test.go +++ b/backend/internal/lxc/lxc_test.go @@ -121,6 +121,29 @@ func TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) { } } +func TestSameFilesystemPathResolvesContainerStorageSymlink(t *testing.T) { + base := t.TempDir() + storageContainer := filepath.Join(base, "storage", "ct-1") + rootfs := filepath.Join(storageContainer, "rootfs") + if err := os.MkdirAll(rootfs, 0755); err != nil { + t.Fatal(err) + } + + lxcPath := filepath.Join(base, "lxc") + if err := os.MkdirAll(lxcPath, 0755); err != nil { + t.Fatal(err) + } + containerLink := filepath.Join(lxcPath, "ct-1") + if err := os.Symlink(storageContainer, containerLink); err != nil { + t.Skipf("directory symlinks are unavailable: %v", err) + } + + linkedRootfs := filepath.Join(containerLink, "rootfs") + if !sameFilesystemPath(rootfs, linkedRootfs) { + t.Fatalf("sameFilesystemPath(%q, %q) = false, want true", rootfs, linkedRootfs) + } +} + func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) { base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n" diff --git a/backend/internal/lxc/snapshot.go b/backend/internal/lxc/snapshot.go index 3a7c8c4..7bb884e 100644 --- a/backend/internal/lxc/snapshot.go +++ b/backend/internal/lxc/snapshot.go @@ -16,7 +16,7 @@ import ( var snapshotMu sync.Mutex -func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) { +func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) { snapshotMu.Lock() defer snapshotMu.Unlock() @@ -42,12 +42,21 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat if _, err := os.Stat(containerDir); err != nil { return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err) } + pool, err := config.SelectStoragePoolForContent( + config.StorageContentSnapshots, + firstString(storagePoolID), + dirSizeBytes(containerDir), + ) + if err != nil { + return config.Snapshot{}, err + } now := time.Now() snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000")) // Use container ID instead of lxcName to avoid collision when containers are recreated - snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID) - if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil { + baseDir := filepath.Join(pool.Path, "snapshots") + snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID) + if err := safePathUnder(snapshotDir, baseDir); err != nil { return config.Snapshot{}, err } if err := os.MkdirAll(snapshotDir, 0700); err != nil { @@ -100,7 +109,7 @@ func (m *Manager) DeleteSnapshot(id string) error { func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error { if snapshot.Path != "" { - if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { + if err := safeSnapshotPath(snapshot.Path); err != nil { return err } if err := os.RemoveAll(snapshot.Path); err != nil { @@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error { if snapshot.Path == "" { return fmt.Errorf("snapshot path is empty") } - if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { + if err := safeSnapshotPath(snapshot.Path); err != nil { return err } if _, err := os.Stat(snapshot.Path); err != nil { @@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD } func snapshotBaseDir() string { - return filepath.Join(config.AppConfig.DataDir, "snapshots") + return snapshotBaseDirForPool("") +} + +func snapshotBaseDirForPool(poolID string) string { + if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil { + return filepath.Join(pool.Path, "snapshots") + } + return "" +} + +func safeSnapshotPath(path string) error { + if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil { + return nil + } + for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) { + if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil { + return nil + } + } + return fmt.Errorf("unsafe snapshot path: %s", path) +} + +func firstString(values []string) string { + if len(values) == 0 { + return "" + } + return strings.TrimSpace(values[0]) } func copyTree(src string, dst string) error { @@ -313,6 +348,9 @@ func copyTree(src string, dst string) error { } func dirSizeBytes(path string) int64 { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } out, err := exec.Command("du", "-s", "-B1", path).Output() if err != nil { return 0 diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 017c1b2..0ca8ca1 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -66,9 +66,11 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan))) mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting))) + mux.HandleFunc("/api/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks)))) mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete)))) + mux.HandleFunc("/api/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings))) mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate))) mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction))) mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate))) @@ -110,9 +112,11 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots)))) mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan))) mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting))) + mux.HandleFunc("/api/v1/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage))) mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks)))) mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete))) + mux.HandleFunc("/api/v1/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings))) mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate))) mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction))) mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate))) diff --git a/backend/main.go b/backend/main.go index 66cd624..d40e512 100644 --- a/backend/main.go +++ b/backend/main.go @@ -52,6 +52,7 @@ func main() { installShutdownStateCapture() // Restore persisted state + api.ConfigureTaskQueue(cfg.TaskConcurrency) api.RestoreTasks() api.RestoreLoginLogs() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9442ba7..3239830 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import Settings from './pages/Settings' import ImageManagement from './pages/ImageManagement' import Snapshots from './pages/Snapshots' import Routing from './pages/Routing' +import Storage from './pages/Storage' import SubUserManagement from './pages/SubUserManagement' import Layout from './components/Layout' @@ -63,6 +64,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/BrowserDialogTranslator.tsx b/frontend/src/components/BrowserDialogTranslator.tsx index 6c05855..17cb521 100644 --- a/frontend/src/components/BrowserDialogTranslator.tsx +++ b/frontend/src/components/BrowserDialogTranslator.tsx @@ -1,19 +1,21 @@ import { useEffect } from 'react' import { useLanguage } from '../contexts/LanguageContext' +import { useDialog } from './Dialog' export default function BrowserDialogTranslator() { const { t } = useLanguage() + const { alert: showAlert } = useDialog() useEffect(() => { const originalAlert = window.alert const originalConfirm = window.confirm - window.alert = (message?: unknown) => originalAlert(t(String(message ?? ''))) + window.alert = (message?: unknown) => { void showAlert('提示', String(message ?? '')) } window.confirm = (message?: string) => originalConfirm(t(String(message ?? ''))) return () => { window.alert = originalAlert window.confirm = originalConfirm } - }, [t]) + }, [showAlert, t]) return null } diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 9473c78..f0213c8 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' import { CalendarClock, RefreshCw, X } from 'lucide-react' -import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, Template } from '../services/api' +import { useNavigate } from 'react-router-dom' +import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, StorageInfo, Template } from '../services/api' import { useDialog } from './Dialog' import { useLanguage, type Language } from '../contexts/LanguageContext' import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth' @@ -16,6 +17,7 @@ const defaultForm: CreateContainerRequest = { name: '', virtualization: 'lxc', template_id: '', + storage_pool_id: '', vcpu: 1, cpu_percent: 100, ram_mb: 512, @@ -54,6 +56,7 @@ const defaultForm: CreateContainerRequest = { } export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) { + const navigate = useNavigate() const dialog = useDialog() const { language } = useLanguage() const networkText = createNetworkText[language] @@ -63,6 +66,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist const [form, setForm] = useState(defaultForm) const [hostInfo, setHostInfo] = useState(null) const [hostReport, setHostReport] = useState(null) + const [storageInfo, setStorageInfo] = useState(null) + const [storageLoading, setStorageLoading] = useState(true) const [ipv6Status, setIPv6Status] = useState(null) const [nameError, setNameError] = useState('') @@ -107,8 +112,26 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist getHostReport() .then((res) => setHostReport(res.data.data || null)) .catch(() => setHostReport(null)) + }, [isOpen, form.virtualization]) + useEffect(() => { + if (!isOpen) return + let active = true + setStorageLoading(true) + getStorageInfo() + .then((res) => { + if (active) setStorageInfo(res.data.data || null) + }) + .catch(() => { + if (active) setStorageInfo(null) + }) + .finally(() => { + if (active) setStorageLoading(false) + }) + return () => { active = false } + }, [isOpen]) + const ipv6Available = !!ipv6Status?.available const ipv6Prefixes = ipv6Status?.prefixes || [] const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '') @@ -118,6 +141,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const kvmAvailable = !!hostInfo?.runtime?.kvm_available + const storagePools = useMemo(() => { + const content = form.virtualization === 'kvm' ? 'kvm' : 'lxc' + return (storageInfo?.pools || []).filter((pool) => pool.enabled && pool.available !== false && (pool.content_types || []).includes(content)) + }, [storageInfo, form.virtualization]) + const storageReady = storagePools.length > 0 useEffect(() => { if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') { @@ -197,6 +225,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist } } + if (!storageReady) { + dialog.alert('未配置存储', `请先在存储管理中为 ${form.virtualization === 'kvm' ? 'KVM 磁盘' : 'LXC 容器'}开启至少一块存储磁盘`) + return + } + const authError = validateSSHAuthInputs(form) if (authError) { dialog.alert('登录方式有误', authError) @@ -273,7 +306,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
+
+ )} + + {templates.length > 0 && (
@@ -821,7 +887,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist - )} +
+ + ) + })} + + {dialog.open && ( +
+
+
+
+ +
+

{t(dialog.title)}

-

{t(dialog.message)}

+

{t(dialog.message)}

-
- {dialog.type === 'confirm' && ( - - )} +
+
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d70edae..50c4916 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { Code2, Cpu, Camera, + HardDrive, LayoutDashboard, LogOut, Moon, @@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { const isSnapshotsPage = location.pathname.startsWith('/snapshots') const isRoutingPage = location.pathname.startsWith('/routing') + const isStoragePage = location.pathname.startsWith('/storage') const isAuditLogsPage = location.pathname.startsWith('/audit-logs') const isApiIntegrationPage = location.pathname.startsWith('/api-integration') const isHostReportPage = location.pathname.startsWith('/host-report') @@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { {!collapsed && 路由管理} + + +
+ )}
快照数量: @@ -1549,6 +1602,26 @@ export default function ContainerDetail() { )}
+ {!isSubUser && snapshotStoragePools.length > 0 && ( +
+ + + +
仅影响手动新建快照;定时快照使用默认磁盘。
+
+ )} + {editingSnapshotQuota && !isSubUser && (
diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index cf8b050..29aae19 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -21,6 +21,7 @@ import { } from 'lucide-react' import CreateContainerModal from '../components/CreateContainerModal' import { useAuth } from '../contexts/AuthContext' +import { useLanguage } from '../contexts/LanguageContext' import { Container, CreateContainerRequest, @@ -391,7 +392,7 @@ export default function Containers() { {pageContainers.map((container) => { const isRunning = container.status === 'running' const isInitializing = container.status === 'initializing' - const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask + const task = (container.id > 0 ? taskStatusMap[container.id] : undefined) || taskNameMap[container.name] || container.createTask const isPlaceholder = !!container.isPlaceholder const isPolicyBlocked = !!container.policy_blocked const usage = usageByName[container.name] @@ -581,12 +582,13 @@ type DisplayContainer = Container & { } function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) { + const { t } = useLanguage() const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap" if (policyBlocked) { return ( - 策略封禁 + {t('策略封禁')} ) } @@ -595,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked } return ( - 初始化失败 + {t('初始化失败')} ) } @@ -604,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked } return ( - 初始化完成 + {t('初始化完成')} ) } if (task?.type === 'create' && task.status === 'running') { + const detail = t(task.stage_detail || '正在初始化') return ( - - - 正在初始化 + + + {detail} ) } @@ -622,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked } return ( - 排队等待 + {t('排队等待')} ) } @@ -634,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked } return ( - {taskLabels[task.type] || '处理中'} + {t(taskLabels[task.type] || '处理中')} ) } @@ -643,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked } return ( - 正在初始化 + {t('正在初始化')} ) } @@ -651,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked } return ( - {running ? '在线' : '离线'} + {t(running ? '在线' : '离线')} ) } @@ -788,7 +791,7 @@ type ContainerFilters = { function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] { const keyword = filters.search.trim().toLowerCase() return containers.filter((container) => { - const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask + const task = (container.id > 0 ? filters.taskStatusMap[container.id] : undefined) || filters.taskNameMap[container.name] || container.createTask if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) { return false } @@ -865,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task) function taskLineLabel(task: Task, actionLabels: Record) { if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败' if (task.type === 'create' && task.status === 'done') return '初始化完成' + if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化' return actionLabels[task.type] || '处理中...' } @@ -873,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: { onRefresh: () => void | Promise onClose: () => void }) { + const { t } = useLanguage() return (
-
+
-

任务队列

-

共 {tasks.length} 个任务

+

{t('任务队列')}

+

{t(`共 ${tasks.length} 个任务`)}

-
{tasks.length === 0 ? ( -
暂无任务
+
{t('暂无任务')}
) : (
- - - - - + + + + + + @@ -915,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: { - + +
状态操作容器创建时间错误{t('状态')}{t('操作')}{t('容器')}{t('当前阶段')}{t('创建时间')}{t('错误')}
- {taskStatusLabel(task.status)} + {t(taskStatusLabel(task.status))} {actionLabel(task.type)}{t(actionLabel(task.type))} {task.container_name} + {task.type === 'create' ? t(task.stage_detail || (task.status === 'pending' ? '排队等待' : '-')) : '-'} + {task.created_at} {task.error || '-'} @@ -932,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: { } catch { /* ignore */ } }} className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors" - title="取消任务" + title={t('取消任务')} > diff --git a/frontend/src/pages/ImageManagement.tsx b/frontend/src/pages/ImageManagement.tsx index eaa0bb1..50aabb5 100644 --- a/frontend/src/pages/ImageManagement.tsx +++ b/frontend/src/pages/ImageManagement.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' import { Download, Trash2, @@ -11,15 +12,18 @@ import { AlertCircle, X, } from 'lucide-react' -import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api' +import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo, StorageInfo } from '../services/api' import { useDialog } from '../components/Dialog' export default function ImageManagement() { const dialog = useDialog() + const navigate = useNavigate() const [images, setImages] = useState([]) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) const [error, setError] = useState('') + const [storageInfo, setStorageInfo] = useState(null) + const [storageLoading, setStorageLoading] = useState(true) const fetchImages = useCallback(async () => { try { @@ -33,9 +37,22 @@ export default function ImageManagement() { } }, []) + const fetchStorage = useCallback(async () => { + setStorageLoading(true) + try { + const res = await getStorageInfo() + setStorageInfo(res.data.data || null) + } catch { + setStorageInfo(null) + } finally { + setStorageLoading(false) + } + }, []) + useEffect(() => { fetchImages() - }, [fetchImages]) + fetchStorage() + }, [fetchImages, fetchStorage]) useEffect(() => { const hasDownloads = images.some((img) => img.downloading) @@ -101,6 +118,9 @@ export default function ImageManagement() { const downloadedCount = images.filter((img) => img.downloaded).length const lxcImages = images.filter((img) => img.type === 'lxc') const kvmImages = images.filter((img) => img.type === 'kvm') + const imageStorageReady = (storageInfo?.pools || []).some((pool) => + pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('images') + ) if (loading) { return ( @@ -121,7 +141,7 @@ export default function ImageManagement() {

+ + )} + {kvmImages.length > 0 && ( @@ -159,6 +200,8 @@ export default function ImageManagement() { onCancelDownload={handleCancelDownload} onDelete={handleDelete} onToggle={handleToggle} + storageReady={imageStorageReady} + storageLoading={storageLoading} /> )} @@ -175,6 +218,8 @@ function ImageTable({ onCancelDownload, onDelete, onToggle, + storageReady, + storageLoading, }: { title: string images: ImageInfo[] @@ -185,6 +230,8 @@ function ImageTable({ onCancelDownload: (id: string) => void onDelete: (id: string) => void onToggle: (id: string, enabled: boolean) => void + storageReady: boolean + storageLoading: boolean }) { return (
@@ -253,7 +300,8 @@ function ImageTable({ {!img.downloaded && !img.downloading && ( + ) + })} + + - +
+ {activeSection === 'tasks' && ( + + )} + + {activeSection === 'account' && ( +
+

+ 账号设置 +

+
+
+ + +
+
+ + setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 3 位" /> +
+
+ + setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 6 位" /> +
+
+ + setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="输入当前密码以确认修改" /> +
+
+
+ +
+
+ )} + + {activeSection === 'webssh' && ( + + )} + + {activeSection === 'ssl' && ( + + )} + + {activeSection === 'logs' && ( + + )} +
+
+ + ) +} + +interface TaskQueueCardProps { + settings: TaskQueueSettings | null + concurrency: number + saving: boolean + onConcurrencyChange: (value: number) => void + onRefresh: () => void + onSave: () => void +} + +function TaskQueueCard(props: TaskQueueCardProps) { + const setBounded = (value: number) => props.onConcurrencyChange(Math.max(1, Math.min(16, value))) + return ( +
+
+

+ 任务队列 +

+ +
+
+
+
运行中
+
{props.settings?.active ?? 0}
- -
-

- 账号设置 -

-
-
- - -
-
- - setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" /> -
-
- - setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" /> -
-
- - setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" /> -
- -
+
+
等待中
+
{props.settings?.pending ?? 0}
- - +
+ +
+ + setBounded(Number(event.target.value) || 1)} + className="min-w-0 flex-1 border-y border-gray-300 px-2 text-center text-sm font-medium text-black outline-none focus:ring-2 focus:ring-inset focus:ring-black" + /> + +
+
+
+ +
) } @@ -292,7 +448,7 @@ interface WebSSHOriginCardProps { function WebSSHOriginCard(props: WebSSHOriginCardProps) { return ( -
+

WebSSH Origin 白名单 @@ -307,7 +463,7 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {