mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-09 15:02:13 +08:00
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user