Files
CLICD/backend/internal/api/storage.go
T
2026-07-18 21:16:29 +08:00

499 lines
15 KiB
Go

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) {
return normalizeStoragePoolsRequestWithDisks(items, detectStorageDisks())
}
func normalizeStoragePoolsRequestWithDisks(items []config.StoragePool, disks []storageDiskInfo) ([]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{}
for _, item := range items {
disk, managedPath, err := storageDiskForPoolRequest(item, disks)
if err != nil {
return nil, err
}
id, name := storagePoolIdentity(disk)
if seen[id] {
return nil, fmt.Errorf("duplicate storage disk: %s", disk.MountPoint)
}
seen[id] = true
contentTypes := normalizeStorageContentTypes(item.ContentTypes)
defaultContents := normalizeStorageContentTypes(item.DefaultContents)
allowed := map[string]bool{}
for _, content := range contentTypes {
allowed[content] = true
}
defaults := make([]string, 0, len(defaultContents))
for _, content := range 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)
}
result = append(result, config.StoragePool{
ID: id,
Name: name,
Path: managedPath,
MountPoint: disk.MountPoint,
ContentTypes: contentTypes,
DefaultContents: defaults,
Enabled: item.Enabled,
})
}
return result, nil
}
func storageDiskForPoolRequest(item config.StoragePool, disks []storageDiskInfo) (storageDiskInfo, string, error) {
requestedMount := filepath.Clean(strings.TrimSpace(item.MountPoint))
if requestedMount == "." {
requestedMount = ""
}
requestedPath := filepath.Clean(strings.TrimSpace(item.Path))
if requestedPath == "." {
requestedPath = ""
}
for _, disk := range disks {
mountPoint := filepath.Clean(disk.MountPoint)
managedPath := managedStoragePath(mountPoint)
mountMatches := requestedMount != "" && requestedMount == mountPoint
pathMatches := requestedPath != "" && requestedPath == managedPath
if !mountMatches && !pathMatches {
continue
}
if requestedMount != "" && !mountMatches {
return storageDiskInfo{}, "", fmt.Errorf("storage disk mount point has changed; refresh and try again")
}
if requestedPath != "" && !pathMatches {
return storageDiskInfo{}, "", fmt.Errorf("custom storage paths are not allowed; refresh and try again")
}
return disk, managedPath, nil
}
return storageDiskInfo{}, "", fmt.Errorf("storage disk is not mounted or is no longer available")
}
func storagePoolIdentity(disk storageDiskInfo) (string, string) {
mountPoint := filepath.Clean(disk.MountPoint)
if mountPoint == string(os.PathSeparator) {
return "disk-root", "system (/)"
}
baseName := filepath.Base(mountPoint)
if baseName == "" || baseName == "." || baseName == string(os.PathSeparator) {
baseName = strings.TrimSpace(disk.Name)
}
if baseName == "" {
baseName = "storage"
}
devicePath := strings.TrimSpace(disk.Path)
if devicePath == "" {
devicePath = strings.TrimSpace(disk.Name)
}
return "disk-" + storageID(baseName), fmt.Sprintf("%s (%s)", baseName, devicePath)
}
func managedStoragePath(mountPoint string) string {
if filepath.Clean(mountPoint) == string(os.PathSeparator) {
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
}
return filepath.Join(filepath.Clean(mountPoint), "clicd")
}
func normalizeStorageContentTypes(values []string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range values {
var next string
switch strings.ToLower(strings.TrimSpace(value)) {
case config.StorageContentLXC:
next = config.StorageContentLXC
case config.StorageContentKVM:
next = config.StorageContentKVM
case config.StorageContentImages:
next = config.StorageContentImages
case config.StorageContentSnapshots:
next = config.StorageContentSnapshots
case config.StorageContentBackups:
next = config.StorageContentBackups
default:
continue
}
if 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
}