chore: bump to 0.2.0
XDG instance layout, multi-file config editor, talosconfig renewal, node upgrade/rollback CLI, operation cancellation, app restart, and removal of now-unneeded migration code.
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -4,6 +4,25 @@ All notable changes to Wild Cloud Central are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-06-21
|
||||
|
||||
### Added
|
||||
- **Talosconfig renewal**: new `wild cluster talosconfig renew` CLI command and API endpoint to regenerate an expired talosconfig client certificate from the existing PKI root — no cluster access required
|
||||
- **Multi-file config editor**: the configuration editor now shows a dropdown to select and edit any instance config file (instance.yaml, per-node configs, per-app configs) instead of a single fixed file
|
||||
- **Node upgrade and rollback**: `wild node upgrade` and `wild node rollback` CLI commands for managing Talos node versions
|
||||
- **Operation cancellation**: long-running operations can now be cancelled from the web UI
|
||||
- **App restart**: deployed apps can be restarted directly from the app detail page
|
||||
- **App script parameters**: app maintenance scripts now support parameters
|
||||
- **NFS server setup**: improved NFS host configuration scripts
|
||||
|
||||
### Changed
|
||||
- **Instance directory layout**: instance data directories now follow an XDG-inspired structure — `config/` (operator-authored), `data/` (durable artifacts), `state/` (runtime state), `cache/` (regeneratable). Instance data files moved to their appropriate locations under this layout
|
||||
- **Services renamed**: "infrastructure" category renamed to "services" for clarity
|
||||
- **In-place backup/restore**: backup and restore operations now work in-place without requiring a separate staging area
|
||||
|
||||
### Removed
|
||||
- Migration code for the XDG directory layout — both instances are now migrated; the `/migrate` and `/migrate/schedules` API endpoints have been removed
|
||||
|
||||
## [0.1.2] - 2026-01-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/backup"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/config"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/context"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/dnsmasq"
|
||||
@@ -117,7 +116,6 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/instances", api.ListInstances).Methods("GET")
|
||||
r.HandleFunc("/api/v1/instances/{name}", api.GetInstance).Methods("GET")
|
||||
r.HandleFunc("/api/v1/instances/{name}", api.DeleteInstance).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/instances/{name}/migrate", api.MigrateInstance).Methods("POST")
|
||||
|
||||
// Setup
|
||||
r.HandleFunc("/api/v1/instances/{name}/setup/status", api.GetSetupStatus).Methods("GET")
|
||||
@@ -127,6 +125,9 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/instances/{name}/config", api.GetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/instances/{name}/config", api.UpdateConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/instances/{name}/config", api.ConfigUpdateBatch).Methods("PATCH")
|
||||
r.HandleFunc("/api/v1/instances/{name}/config/files", api.ListConfigFiles).Methods("GET")
|
||||
r.HandleFunc("/api/v1/instances/{name}/config/file", api.GetConfigFile).Methods("GET")
|
||||
r.HandleFunc("/api/v1/instances/{name}/config/file", api.UpdateConfigFile).Methods("PUT")
|
||||
|
||||
// Secrets management
|
||||
r.HandleFunc("/api/v1/instances/{name}/secrets", api.GetSecrets).Methods("GET")
|
||||
@@ -192,6 +193,7 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/instances/{name}/cluster/kubeconfig", api.ClusterGetKubeconfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/instances/{name}/cluster/kubeconfig/generate", api.ClusterGenerateKubeconfig).Methods("POST")
|
||||
r.HandleFunc("/api/v1/instances/{name}/cluster/talosconfig", api.ClusterGetTalosconfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/instances/{name}/cluster/talosconfig/renew", api.ClusterRenewTalosconfig).Methods("POST")
|
||||
r.HandleFunc("/api/v1/instances/{name}/cluster/reset", api.ClusterReset).Methods("POST")
|
||||
|
||||
// Apps (unified: includes both applications and infrastructure services)
|
||||
@@ -251,9 +253,6 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/instances/{name}/backup/config", api.BackupConfigGet).Methods("GET")
|
||||
r.HandleFunc("/api/v1/instances/{name}/backup/config", api.BackupConfigUpdate).Methods("PUT")
|
||||
|
||||
// Migration
|
||||
r.HandleFunc("/api/v1/instances/{name}/migrate/schedules", api.MigrateSchedules).Methods("POST")
|
||||
|
||||
// Global Configuration
|
||||
r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).Methods("PUT")
|
||||
@@ -379,28 +378,6 @@ func (api *API) DeleteInstance(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// MigrateInstance migrates a legacy instance directory to the XDG-based layout.
|
||||
func (api *API) MigrateInstance(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
name := vars["name"]
|
||||
|
||||
// Migrate backup schedules first so backup.schedules is stripped from config.yaml
|
||||
// before instance.yaml is assembled.
|
||||
if err := backup.MigrateSchedules(api.dataDir, name); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to migrate schedules: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.instance.MigrateInstance(name); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to migrate instance: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Instance migrated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetConfig retrieves instance configuration
|
||||
func (api *API) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
@@ -554,7 +531,7 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "running",
|
||||
"version": "0.1.0", // TODO: Get from build info
|
||||
"version": "0.2.0", // TODO: Get from build info
|
||||
"uptime": uptime.String(),
|
||||
"uptimeSeconds": int(uptime.Seconds()),
|
||||
"dataDir": dataDir,
|
||||
|
||||
@@ -474,7 +474,7 @@ func (api *API) BackupAppDiscoverResources(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
// Check if app exists
|
||||
appPath := filepath.Join(api.dataDir, "instances", instanceName, "apps", appName)
|
||||
appPath := tools.GetAppPackagePath(api.dataDir, instanceName, appName)
|
||||
if _, err := os.Stat(appPath); os.IsNotExist(err) {
|
||||
respondError(w, http.StatusNotFound, fmt.Sprintf("App not found: %s", appName))
|
||||
return
|
||||
@@ -680,18 +680,11 @@ func discoverDatabases(dataDir, instanceName, appName, manifestPath string) []Ba
|
||||
|
||||
// Check requires section
|
||||
if requires, ok := manifest["requires"].([]any); ok {
|
||||
// Read config.yaml to get database names
|
||||
configPath := tools.GetInstanceConfigPath(dataDir, instanceName)
|
||||
configData, _ := os.ReadFile(configPath)
|
||||
var config map[string]any
|
||||
_ = yaml.Unmarshal(configData, &config)
|
||||
|
||||
// Read per-app config to get database names
|
||||
appConfigPath := tools.GetAppConfigPath(dataDir, instanceName, appName)
|
||||
appConfigData, _ := os.ReadFile(appConfigPath)
|
||||
appConfig := map[string]any{}
|
||||
if apps, ok := config["apps"].(map[string]any); ok {
|
||||
if ac, ok := apps[appName].(map[string]any); ok {
|
||||
appConfig = ac
|
||||
}
|
||||
}
|
||||
_ = yaml.Unmarshal(appConfigData, &appConfig)
|
||||
|
||||
for _, req := range requires {
|
||||
if reqMap, ok := req.(map[string]any); ok {
|
||||
|
||||
@@ -30,7 +30,8 @@ func TestBackupAppDiscoverResources(t *testing.T) {
|
||||
wildDir := filepath.Join(tmpDir, "wild-directory")
|
||||
|
||||
// Create directory structure
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "instances", "test-instance", "apps"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "instances", "test-instance", "config"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "instances", "test-instance", "data", "apps"), 0755))
|
||||
require.NoError(t, os.MkdirAll(wildDir, 0755))
|
||||
|
||||
// Create test API instance
|
||||
@@ -48,31 +49,15 @@ func TestBackupAppDiscoverResources(t *testing.T) {
|
||||
"cloud": map[string]interface{}{
|
||||
"domain": "test.cloud",
|
||||
},
|
||||
"apps": map[string]interface{}{
|
||||
"postgres": map[string]interface{}{
|
||||
"host": "postgres.postgres.svc.cluster.local",
|
||||
"port": "5432",
|
||||
},
|
||||
"mysql": map[string]interface{}{
|
||||
"host": "mysql.mysql.svc.cluster.local",
|
||||
"port": "3306",
|
||||
},
|
||||
"redis": map[string]interface{}{
|
||||
"host": "redis.redis.svc.cluster.local",
|
||||
"port": "6379",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
configPath := filepath.Join(dataDir, "instances", "test-instance", "config.yaml")
|
||||
configPath := filepath.Join(dataDir, "instances", "test-instance", "config", "instance.yaml")
|
||||
configData, _ := yaml.Marshal(instanceConfig)
|
||||
require.NoError(t, os.WriteFile(configPath, configData, 0644))
|
||||
|
||||
// Create secrets file with proper permissions
|
||||
secretsPath := filepath.Join(dataDir, "instances", "test-instance", "secrets.yaml")
|
||||
secretsData := map[string]interface{}{
|
||||
"apps": map[string]interface{}{},
|
||||
}
|
||||
secretsPath := filepath.Join(dataDir, "instances", "test-instance", "config", "secrets.yaml")
|
||||
secretsData := map[string]interface{}{}
|
||||
secretsYAML, _ := yaml.Marshal(secretsData)
|
||||
require.NoError(t, os.WriteFile(secretsPath, secretsYAML, 0600))
|
||||
|
||||
@@ -89,7 +74,7 @@ func TestBackupAppDiscoverResources(t *testing.T) {
|
||||
instanceName: "test-instance",
|
||||
appName: "test-app-pvc",
|
||||
setupApp: func() {
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "apps", "test-app-pvc")
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "data", "apps", "test-app-pvc")
|
||||
require.NoError(t, os.MkdirAll(appPath, 0755))
|
||||
|
||||
// Create kustomization.yaml
|
||||
@@ -155,7 +140,7 @@ description: Test app with PVC`
|
||||
instanceName: "test-instance",
|
||||
appName: "test-app-sts",
|
||||
setupApp: func() {
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "apps", "test-app-sts")
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "data", "apps", "test-app-sts")
|
||||
require.NoError(t, os.MkdirAll(appPath, 0755))
|
||||
|
||||
// Create kustomization.yaml
|
||||
@@ -238,7 +223,7 @@ description: Test app with StatefulSet`
|
||||
instanceName: "test-instance",
|
||||
appName: "redis",
|
||||
setupApp: func() {
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "apps", "redis")
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "data", "apps", "redis")
|
||||
require.NoError(t, os.MkdirAll(appPath, 0755))
|
||||
|
||||
// Create kustomization.yaml
|
||||
@@ -298,7 +283,7 @@ description: Redis cache`
|
||||
instanceName: "test-instance",
|
||||
appName: "app-with-db",
|
||||
setupApp: func() {
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "apps", "app-with-db")
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "data", "apps", "app-with-db")
|
||||
require.NoError(t, os.MkdirAll(appPath, 0755))
|
||||
|
||||
// Create kustomization.yaml
|
||||
@@ -325,13 +310,12 @@ requires:
|
||||
installedAs: postgres`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(appPath, "manifest.yaml"), []byte(manifest), 0644))
|
||||
|
||||
// Update config to include this app with dbName
|
||||
config := instanceConfig
|
||||
config["apps"].(map[string]interface{})["app-with-db"] = map[string]interface{}{
|
||||
"dbName": "appdb",
|
||||
}
|
||||
configData, _ := yaml.Marshal(config)
|
||||
require.NoError(t, os.WriteFile(configPath, configData, 0644))
|
||||
// Create per-app config with dbName
|
||||
appConfigDir := filepath.Join(dataDir, "instances", "test-instance", "config", "apps", "app-with-db")
|
||||
require.NoError(t, os.MkdirAll(appConfigDir, 0755))
|
||||
appConfig := map[string]interface{}{"dbName": "appdb"}
|
||||
appConfigData, _ := yaml.Marshal(appConfig)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(appConfigDir, "config.yaml"), appConfigData, 0644))
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedResult: map[string]interface{}{
|
||||
@@ -359,7 +343,7 @@ requires:
|
||||
instanceName: "test-instance",
|
||||
appName: "app-with-cache",
|
||||
setupApp: func() {
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "apps", "app-with-cache")
|
||||
appPath := filepath.Join(dataDir, "instances", "test-instance", "data", "apps", "app-with-cache")
|
||||
require.NoError(t, os.MkdirAll(appPath, 0755))
|
||||
|
||||
// Create kustomization.yaml
|
||||
@@ -386,13 +370,12 @@ requires:
|
||||
installedAs: redis`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(appPath, "manifest.yaml"), []byte(manifest), 0644))
|
||||
|
||||
// Update config
|
||||
config := instanceConfig
|
||||
config["apps"].(map[string]interface{})["app-with-cache"] = map[string]interface{}{
|
||||
"dbName": "appcache",
|
||||
}
|
||||
configData, _ := yaml.Marshal(config)
|
||||
require.NoError(t, os.WriteFile(configPath, configData, 0644))
|
||||
// Create per-app config with dbName
|
||||
appConfigDir := filepath.Join(dataDir, "instances", "test-instance", "config", "apps", "app-with-cache")
|
||||
require.NoError(t, os.MkdirAll(appConfigDir, 0755))
|
||||
appConfig := map[string]interface{}{"dbName": "appcache"}
|
||||
appConfigData, _ := yaml.Marshal(appConfig)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(appConfigDir, "config.yaml"), appConfigData, 0644))
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedResult: map[string]interface{}{
|
||||
@@ -988,7 +971,8 @@ func TestBackupAppOperations(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create instance directory
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "instances", "test-instance", "apps", "test-app"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "instances", "test-instance", "config"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "instances", "test-instance", "data", "apps", "test-app"), 0755))
|
||||
|
||||
// Create instance config
|
||||
config := map[string]interface{}{
|
||||
@@ -996,15 +980,13 @@ func TestBackupAppOperations(t *testing.T) {
|
||||
"email": "test@example.com",
|
||||
},
|
||||
}
|
||||
configPath := filepath.Join(dataDir, "instances", "test-instance", "config.yaml")
|
||||
configPath := filepath.Join(dataDir, "instances", "test-instance", "config", "instance.yaml")
|
||||
configData, _ := yaml.Marshal(config)
|
||||
require.NoError(t, os.WriteFile(configPath, configData, 0644))
|
||||
|
||||
// Create secrets file with proper permissions
|
||||
secretsPath := filepath.Join(dataDir, "instances", "test-instance", "secrets.yaml")
|
||||
secretsData := map[string]interface{}{
|
||||
"apps": map[string]interface{}{},
|
||||
}
|
||||
secretsPath := filepath.Join(dataDir, "instances", "test-instance", "config", "secrets.yaml")
|
||||
secretsData := map[string]interface{}{}
|
||||
secretsYAML, _ := yaml.Marshal(secretsData)
|
||||
require.NoError(t, os.WriteFile(secretsPath, secretsYAML, 0600))
|
||||
|
||||
|
||||
@@ -187,6 +187,19 @@ func (api *API) ClusterGetTalosconfig(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ClusterRenewTalosconfig regenerates the talosconfig client certificate from the existing PKI root.
|
||||
func (api *API) ClusterRenewTalosconfig(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName := GetInstanceName(r)
|
||||
|
||||
clusterMgr := cluster.NewManager(api.dataDir, api.opsMgr)
|
||||
if err := clusterMgr.RenewTalosconfig(instanceName); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to renew talosconfig: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
respondMessage(w, http.StatusOK, "Talosconfig renewed successfully")
|
||||
}
|
||||
|
||||
// ClusterReset resets the cluster
|
||||
func (api *API) ClusterReset(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName := GetInstanceName(r)
|
||||
|
||||
@@ -3,12 +3,149 @@ package v1
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/config"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/storage"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ConfigFile describes a single editable config file within an instance.
|
||||
type ConfigFile struct {
|
||||
Path string `json:"path"` // relative to instance root, e.g. "config/instance.yaml"
|
||||
Label string `json:"label"` // human-readable, e.g. "Instance Config"
|
||||
}
|
||||
|
||||
// ListConfigFiles returns all editable config files for an instance.
|
||||
// GET /api/v1/instances/{name}/config/files
|
||||
func (api *API) ListConfigFiles(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName := GetInstanceName(r)
|
||||
instancePath := tools.GetInstancePath(api.dataDir, instanceName)
|
||||
|
||||
var files []ConfigFile
|
||||
|
||||
// config/instance.yaml
|
||||
if storage.FileExists(tools.GetInstanceYamlPath(api.dataDir, instanceName)) {
|
||||
files = append(files, ConfigFile{Path: "config/instance.yaml", Label: "Instance Config"})
|
||||
}
|
||||
|
||||
// config/nodes/*.yaml
|
||||
nodeDir := tools.GetNodeConfigDirPath(api.dataDir, instanceName)
|
||||
if entries, err := os.ReadDir(nodeDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
||||
continue
|
||||
}
|
||||
hostname := strings.TrimSuffix(entry.Name(), ".yaml")
|
||||
files = append(files, ConfigFile{
|
||||
Path: "config/nodes/" + entry.Name(),
|
||||
Label: "Node: " + hostname,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// config/apps/*/config.yaml
|
||||
appsConfigDir := filepath.Join(instancePath, "config", "apps")
|
||||
if entries, err := os.ReadDir(appsConfigDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
appName := entry.Name()
|
||||
if storage.FileExists(filepath.Join(appsConfigDir, appName, "config.yaml")) {
|
||||
files = append(files, ConfigFile{
|
||||
Path: "config/apps/" + appName + "/config.yaml",
|
||||
Label: "App: " + appName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if files == nil {
|
||||
files = []ConfigFile{}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{"files": files})
|
||||
}
|
||||
|
||||
// GetConfigFile returns the raw YAML content of a specific config file.
|
||||
// GET /api/v1/instances/{name}/config/file?path=config/instance.yaml
|
||||
func (api *API) GetConfigFile(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName := GetInstanceName(r)
|
||||
relPath := r.URL.Query().Get("path")
|
||||
|
||||
if !isAllowedConfigPath(relPath) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid file path")
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(tools.GetInstancePath(api.dataDir, instanceName), relPath)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
respondError(w, http.StatusNotFound, "Config file not found")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to read config file")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// UpdateConfigFile replaces the content of a specific config file.
|
||||
// PUT /api/v1/instances/{name}/config/file?path=config/instance.yaml
|
||||
func (api *API) UpdateConfigFile(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName := GetInstanceName(r)
|
||||
relPath := r.URL.Query().Get("path")
|
||||
|
||||
if !isAllowedConfigPath(relPath) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid file path")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to read request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate it parses as valid YAML before writing
|
||||
var parsed map[string]interface{}
|
||||
if err := yaml.Unmarshal(body, &parsed); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid YAML format")
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(tools.GetInstancePath(api.dataDir, instanceName), relPath)
|
||||
lockPath := fullPath + ".lock"
|
||||
if err := storage.WithLock(lockPath, func() error {
|
||||
return storage.WriteFile(fullPath, body, 0644)
|
||||
}); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to write config file")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("config file updated", "instance", instanceName, "path", relPath)
|
||||
respondMessage(w, http.StatusOK, "Config file updated successfully")
|
||||
}
|
||||
|
||||
// isAllowedConfigPath checks that a relative path is safe to read/write:
|
||||
// must start with "config/" and must not contain "..".
|
||||
func isAllowedConfigPath(relPath string) bool {
|
||||
return relPath != "" &&
|
||||
strings.HasPrefix(relPath, "config/") &&
|
||||
!strings.Contains(relPath, "..")
|
||||
}
|
||||
|
||||
// ConfigUpdateBatch updates multiple configuration values atomically
|
||||
func (api *API) ConfigUpdateBatch(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName := GetInstanceName(r)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/backup"
|
||||
)
|
||||
|
||||
// MigrateSchedules extracts backup.schedules[] from the legacy config.yaml into
|
||||
// per-app config/apps/{app}/schedule.yaml and cache/schedules/{app}.yaml files.
|
||||
// POST /api/v1/instances/{name}/migrate/schedules
|
||||
func (api *API) MigrateSchedules(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName := GetInstanceName(r)
|
||||
if err := api.instance.ValidateInstance(instanceName); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Instance not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := backup.MigrateSchedules(api.dataDir, instanceName); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{"success": true})
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
|
||||
Timestamp: time.Now(),
|
||||
Data: map[string]interface{}{
|
||||
"status": "running",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"uptime": uptime.String(),
|
||||
"uptimeSeconds": int(uptime.Seconds()),
|
||||
"instances": map[string]interface{}{
|
||||
|
||||
@@ -90,6 +90,13 @@ func (api *API) ValidateInstanceMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip validation for the migrate endpoint — it handles legacy instances
|
||||
// that don't yet have the new-format config/instance.yaml.
|
||||
if strings.HasSuffix(r.URL.Path, "/migrate") || strings.Contains(r.URL.Path, "/migrate/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate instance exists
|
||||
if err := api.instance.ValidateInstance(instanceName); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Instance not found")
|
||||
|
||||
@@ -34,47 +34,29 @@ func NewManager(dataDir, appsDir string) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// getAppPackageDir returns the directory for a compiled app package (format-aware).
|
||||
// getAppPackageDir returns the directory for a compiled app package.
|
||||
func (m *Manager) getAppPackageDir(instanceName, appName string) string {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return tools.GetAppPackagePath(m.dataDir, instanceName, appName)
|
||||
}
|
||||
return filepath.Join(tools.GetInstancePath(m.dataDir, instanceName), "apps", appName)
|
||||
return tools.GetAppPackagePath(m.dataDir, instanceName, appName)
|
||||
}
|
||||
|
||||
// getAppsListDir returns the parent directory that contains all compiled app packages.
|
||||
func (m *Manager) getAppsListDir(instanceName string) string {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return filepath.Join(m.dataDir, "instances", instanceName, "data", "apps")
|
||||
}
|
||||
return filepath.Join(tools.GetInstancePath(m.dataDir, instanceName), "apps")
|
||||
return filepath.Join(m.dataDir, "instances", instanceName, "data", "apps")
|
||||
}
|
||||
|
||||
// getAppConfigFile returns the config file path for an app (format-aware).
|
||||
// In new format: config/apps/{appName}/config.yaml; legacy: the monolithic config.yaml.
|
||||
// getAppConfigFile returns the config file path for an app.
|
||||
func (m *Manager) getAppConfigFile(instanceName, appName string) string {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return tools.GetAppConfigPath(m.dataDir, instanceName, appName)
|
||||
}
|
||||
return tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
return tools.GetAppConfigPath(m.dataDir, instanceName, appName)
|
||||
}
|
||||
|
||||
// getAppSecretsFile returns the secrets file path for an app (format-aware).
|
||||
// In new format: config/apps/{appName}/secrets.yaml; legacy: the monolithic secrets.yaml.
|
||||
// getAppSecretsFile returns the secrets file path for an app.
|
||||
func (m *Manager) getAppSecretsFile(instanceName, appName string) string {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return tools.GetAppSecretsPath(m.dataDir, instanceName, appName)
|
||||
}
|
||||
return tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
||||
return tools.GetAppSecretsPath(m.dataDir, instanceName, appName)
|
||||
}
|
||||
|
||||
// appConfigKeyPrefix returns the yq key prefix for app config access.
|
||||
// In new format: "." (root of per-app file); legacy: ".apps.{appName}".
|
||||
func (m *Manager) appConfigKeyPrefix(instanceName, appName string) string {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return "."
|
||||
}
|
||||
return fmt.Sprintf(".apps.%s", appName)
|
||||
return "."
|
||||
}
|
||||
|
||||
// resolveAppDir returns the filesystem path containing the app's files to use.
|
||||
@@ -159,7 +141,7 @@ func (m *Manager) ResolveNamespace(instanceName, appName string) string {
|
||||
configFile := m.getAppConfigFile(instanceName, appName)
|
||||
prefix := m.appConfigKeyPrefix(instanceName, appName)
|
||||
yq := tools.NewYQ()
|
||||
if ns, err := yq.Get(configFile, prefix+".namespace"); err == nil && ns != "" && ns != "null" {
|
||||
if ns, err := yq.Get(configFile, prefix+"namespace"); err == nil && ns != "" && ns != "null" {
|
||||
return strings.TrimSpace(ns)
|
||||
}
|
||||
|
||||
@@ -546,9 +528,13 @@ func processSecretTemplate(template string, appName string, configMap map[string
|
||||
}
|
||||
|
||||
if apps, ok := allSecrets["apps"].(map[string]interface{}); ok {
|
||||
// Old monolithic format: apps.{appName}.*
|
||||
if appSecrets, ok := apps[appName].(map[string]interface{}); ok {
|
||||
context["secrets"] = appSecrets
|
||||
}
|
||||
} else {
|
||||
// Per-app format: root-level keys
|
||||
context["secrets"] = allSecrets
|
||||
}
|
||||
|
||||
contextYAML, err := yaml.Marshal(context)
|
||||
@@ -572,7 +558,7 @@ func ensureDefaultSecrets(secretDefs []SecretDefinition, appName string, configM
|
||||
gomplate := tools.NewGomplate()
|
||||
|
||||
for _, secretDef := range secretDefs {
|
||||
secretPath := fmt.Sprintf("apps.%s.%s", appName, secretDef.Key)
|
||||
secretPath := secretDef.Key
|
||||
|
||||
existingSecret, _ := secretsMgr.GetSecret(secretsFile, secretPath)
|
||||
if existingSecret != "" && existingSecret != "null" {
|
||||
@@ -648,14 +634,18 @@ func (m *Manager) Add(instanceName, appName, version string, config map[string]i
|
||||
secretsFile := m.getAppSecretsFile(instanceName, appName)
|
||||
keyPrefix := m.appConfigKeyPrefix(instanceName, appName)
|
||||
|
||||
// Check instance config exists (legacy: config.yaml; new format guaranteed by IsNewInstanceFormat)
|
||||
if !tools.IsNewInstanceFormat(m.dataDir, instanceName) && !storage.FileExists(appConfigFile) {
|
||||
return fmt.Errorf("instance config not found: %s", instanceName)
|
||||
// Ensure per-app config directory and files exist
|
||||
if err := os.MkdirAll(filepath.Dir(appConfigFile), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create app config dir: %w", err)
|
||||
}
|
||||
// Ensure per-app config directory exists (new format only)
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
if err := os.MkdirAll(filepath.Dir(appConfigFile), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create app config dir: %w", err)
|
||||
if !storage.FileExists(appConfigFile) {
|
||||
if err := os.WriteFile(appConfigFile, []byte("{}\n"), 0644); err != nil {
|
||||
return fmt.Errorf("failed to create app config file: %w", err)
|
||||
}
|
||||
}
|
||||
if !storage.FileExists(secretsFile) {
|
||||
if err := os.WriteFile(secretsFile, []byte("{}\n"), 0600); err != nil {
|
||||
return fmt.Errorf("failed to create app secrets file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,14 +685,6 @@ func (m *Manager) Add(instanceName, appName, version string, config map[string]i
|
||||
configLock := appConfigFile + ".lock"
|
||||
|
||||
if err := storage.WithLock(configLock, func() error {
|
||||
// In legacy format, ensure apps section exists in config.yaml
|
||||
if !tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
expr := fmt.Sprintf(".apps.%s = .apps.%s // {}", appName, appName)
|
||||
if _, err := yq.Exec("-i", expr, appConfigFile); err != nil {
|
||||
return fmt.Errorf("failed to ensure apps section: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Process config in order from manifest YAML to handle {{ .app.X }} references correctly
|
||||
// Use the source manifest since the destination hasn't been copied yet
|
||||
sourceManifestPath := filepath.Join(sourceAppDir, "manifest.yaml")
|
||||
@@ -719,7 +701,7 @@ func (m *Manager) Add(instanceName, appName, version string, config map[string]i
|
||||
}
|
||||
|
||||
for key, value := range processedConfig {
|
||||
keyPath := keyPrefix + "." + key
|
||||
keyPath := keyPrefix + key
|
||||
// Use setNestedConfig to handle both simple values and nested objects
|
||||
if err := setNestedConfig(yq, appConfigFile, keyPath, value); err != nil {
|
||||
return fmt.Errorf("failed to set config %s: %w", key, err)
|
||||
@@ -945,6 +927,7 @@ func (m *Manager) Deploy(instanceName, appName string, opID string, broadcaster
|
||||
// Create Kubernetes secrets from secrets.yaml
|
||||
if storage.FileExists(secretsFile) {
|
||||
secretsMgr := secrets.NewManager()
|
||||
instanceSecretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
||||
|
||||
// Create app-secrets (from defaultSecrets + requiredSecrets)
|
||||
deleteCmd := exec.Command("kubectl", "delete", "secret", fmt.Sprintf("%s-secrets", appName), "-n", namespace, "--ignore-not-found")
|
||||
@@ -955,8 +938,8 @@ func (m *Manager) Deploy(instanceName, appName string, opID string, broadcaster
|
||||
|
||||
if len(manifest.DefaultSecrets) > 0 {
|
||||
for _, secretDef := range manifest.DefaultSecrets {
|
||||
secretPath := fmt.Sprintf("apps.%s.%s", appName, secretDef.Key)
|
||||
secretValue, err := secretsMgr.GetSecret(secretsFile, secretPath)
|
||||
// Per-app secrets file has root-level keys
|
||||
secretValue, err := secretsMgr.GetSecret(secretsFile, secretDef.Key)
|
||||
if err == nil && secretValue != "" && secretValue != "null" {
|
||||
createSecretCmd.Args = append(createSecretCmd.Args, fmt.Sprintf("--from-literal=%s=%s", secretDef.Key, secretValue))
|
||||
}
|
||||
@@ -965,10 +948,11 @@ func (m *Manager) Deploy(instanceName, appName string, opID string, broadcaster
|
||||
|
||||
if len(manifest.RequiredSecrets) > 0 {
|
||||
for _, requiredSecret := range manifest.RequiredSecrets {
|
||||
// Cross-app secrets live in the monolithic instance secrets file
|
||||
secretPath := fmt.Sprintf("apps.%s", requiredSecret)
|
||||
secretValue, err := secretsMgr.GetSecret(secretsFile, secretPath)
|
||||
secretValue, err := secretsMgr.GetSecret(instanceSecretsFile, secretPath)
|
||||
if err != nil || secretValue == "" || secretValue == "null" {
|
||||
secretValue, _ = secretsMgr.GetSecret(secretsFile, requiredSecret)
|
||||
secretValue, _ = secretsMgr.GetSecret(instanceSecretsFile, requiredSecret)
|
||||
}
|
||||
if secretValue != "" && secretValue != "null" {
|
||||
createSecretCmd.Args = append(createSecretCmd.Args,
|
||||
@@ -998,12 +982,12 @@ func (m *Manager) Deploy(instanceName, appName string, opID string, broadcaster
|
||||
|
||||
createCmd := exec.Command("kubectl", "create", "secret", "generic", cs.Name, "-n", targetNs)
|
||||
for k8sKey, secretRef := range cs.Entries {
|
||||
// Look up from app's secrets first, then from source app
|
||||
secretPath := fmt.Sprintf("apps.%s.%s", appName, secretRef)
|
||||
secretValue, err := secretsMgr.GetSecret(secretsFile, secretPath)
|
||||
// Try per-app secrets file first (root-level key)
|
||||
secretValue, err := secretsMgr.GetSecret(secretsFile, secretRef)
|
||||
if err != nil || secretValue == "" || secretValue == "null" {
|
||||
secretPath = fmt.Sprintf("apps.%s", secretRef)
|
||||
secretValue, _ = secretsMgr.GetSecret(secretsFile, secretPath)
|
||||
// Fall back to monolithic instance secrets file
|
||||
secretPath := fmt.Sprintf("apps.%s", secretRef)
|
||||
secretValue, _ = secretsMgr.GetSecret(instanceSecretsFile, secretPath)
|
||||
}
|
||||
if secretValue != "" && secretValue != "null" {
|
||||
createCmd.Args = append(createCmd.Args, fmt.Sprintf("--from-literal=%s=%s", k8sKey, secretValue))
|
||||
@@ -1180,37 +1164,13 @@ func (m *Manager) Delete(instanceName, appName string) error {
|
||||
}
|
||||
|
||||
// Remove app config
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
// Per-app config file: just remove it
|
||||
if err := os.Remove(appConfigFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove app config: %w", err)
|
||||
}
|
||||
} else {
|
||||
yq := tools.NewYQ()
|
||||
configLock := appConfigFile + ".lock"
|
||||
if storage.FileExists(appConfigFile) {
|
||||
if err := storage.WithLock(configLock, func() error {
|
||||
return yq.Delete(appConfigFile, fmt.Sprintf(".apps.%s", appName))
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to remove app config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Remove(appConfigFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove app config: %w", err)
|
||||
}
|
||||
|
||||
// Remove app secrets
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
if err := os.Remove(secretsFile); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("failed to remove app secrets", "component", "apps", "app", appName, "error", err)
|
||||
}
|
||||
} else {
|
||||
secretsMgr := secrets.NewManager()
|
||||
if storage.FileExists(secretsFile) {
|
||||
if err := secretsMgr.DeleteSecret(secretsFile, fmt.Sprintf("apps.%s", appName)); err != nil {
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
return fmt.Errorf("failed to remove app secrets: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.Remove(secretsFile); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("failed to remove app secrets", "component", "apps", "app", appName, "error", err)
|
||||
}
|
||||
|
||||
slog.Info("app deleted", "component", "apps", "instance", instanceName, "app", appName)
|
||||
@@ -1965,8 +1925,8 @@ func (m *Manager) applyConfigMigrations(instanceName, appName string, migrations
|
||||
configLock := configFile + ".lock"
|
||||
return storage.WithLock(configLock, func() error {
|
||||
for oldKey, newKey := range migrations {
|
||||
oldPath := prefix + "." + oldKey
|
||||
newPath := prefix + "." + newKey
|
||||
oldPath := prefix + oldKey
|
||||
newPath := prefix + newKey
|
||||
|
||||
value, err := yq.Get(configFile, oldPath)
|
||||
if err != nil || value == "" || value == "null" {
|
||||
@@ -2069,7 +2029,7 @@ func (m *Manager) UpdateConfig(instanceName, appName string, config map[string]i
|
||||
|
||||
if err := storage.WithLock(configLock, func() error {
|
||||
for key, value := range config {
|
||||
keyPath := prefix + "." + key
|
||||
keyPath := prefix + key
|
||||
if err := setNestedConfig(yq, configFile, keyPath, value); err != nil {
|
||||
return fmt.Errorf("failed to set config %s: %w", key, err)
|
||||
}
|
||||
@@ -2540,7 +2500,7 @@ func processConfigInOrder(manifestPath string, appName string, configFile string
|
||||
valueNode := defaultConfigNode.Content[i+1]
|
||||
|
||||
key := keyNode.Value
|
||||
keyPath := keyPrefix + "." + key
|
||||
keyPath := keyPrefix + key
|
||||
|
||||
// Check if already exists in the config file (yq reads from actual file)
|
||||
existing, _ := yq.Get(configFile, keyPath)
|
||||
|
||||
@@ -188,29 +188,34 @@ func TestAddAppWithNestedConfig(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "loomio"
|
||||
|
||||
// Setup directory structure
|
||||
// Setup directory structure (new XDG format)
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config", "apps", "smtp"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create instance config
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
// Create instance config (cloud section only, no apps)
|
||||
instanceConfigFile := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
instanceConfig := `cloud:
|
||||
domain: example.com
|
||||
apps:
|
||||
smtp:
|
||||
host: mail.example.com
|
||||
port: "587"
|
||||
user: admin@example.com
|
||||
from: no-reply@example.com
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(instanceConfig), 0644); err != nil {
|
||||
if err := os.WriteFile(instanceConfigFile, []byte(instanceConfig), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create secrets file
|
||||
secretsFile := filepath.Join(instancePath, "secrets.yaml")
|
||||
// Create smtp per-app config (cross-app dependency)
|
||||
smtpConfigFile := filepath.Join(instancePath, "config", "apps", "smtp", "config.yaml")
|
||||
smtpConfig := `host: mail.example.com
|
||||
port: "587"
|
||||
user: admin@example.com
|
||||
from: no-reply@example.com
|
||||
`
|
||||
if err := os.WriteFile(smtpConfigFile, []byte(smtpConfig), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create instance secrets file
|
||||
secretsFile := filepath.Join(instancePath, "config", "secrets.yaml")
|
||||
if err := os.WriteFile(secretsFile, []byte("{}"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -241,7 +246,7 @@ apps:
|
||||
},
|
||||
},
|
||||
DefaultSecrets: []SecretDefinition{
|
||||
{Key: "apps.loomio.dbPassword"},
|
||||
{Key: "dbPassword"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -271,11 +276,12 @@ namespace: loomio
|
||||
t.Fatalf("Failed to add app: %v", err)
|
||||
}
|
||||
|
||||
// Verify the nested config was properly set
|
||||
// Verify the nested config was properly set in per-app config file
|
||||
yq := tools.NewYQ()
|
||||
loomioConfigFile := filepath.Join(instancePath, "config", "apps", appName, "config.yaml")
|
||||
|
||||
// Check flat values
|
||||
domain, err := yq.Get(configFile, ".apps.loomio.domain")
|
||||
domain, err := yq.Get(loomioConfigFile, ".domain")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get domain: %v", err)
|
||||
}
|
||||
@@ -285,14 +291,14 @@ namespace: loomio
|
||||
|
||||
// Check nested SMTP values
|
||||
smtpChecks := map[string]string{
|
||||
".apps.loomio.smtp.host": "mail.example.com",
|
||||
".apps.loomio.smtp.port": "587",
|
||||
".apps.loomio.smtp.username": "admin@example.com",
|
||||
".apps.loomio.smtp.useSSL": "true",
|
||||
".apps.loomio.smtp.from": "no-reply@example.com",
|
||||
".smtp.host": "mail.example.com",
|
||||
".smtp.port": "587",
|
||||
".smtp.username": "admin@example.com",
|
||||
".smtp.useSSL": "true",
|
||||
".smtp.from": "no-reply@example.com",
|
||||
}
|
||||
for path, expected := range smtpChecks {
|
||||
result, err := yq.Get(configFile, path)
|
||||
result, err := yq.Get(loomioConfigFile, path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get %s: %v", path, err)
|
||||
}
|
||||
@@ -303,11 +309,11 @@ namespace: loomio
|
||||
|
||||
// Check nested storage values
|
||||
storageChecks := map[string]string{
|
||||
".apps.loomio.storage.uploads": "5Gi",
|
||||
".apps.loomio.storage.files": "10Gi",
|
||||
".storage.uploads": "5Gi",
|
||||
".storage.files": "10Gi",
|
||||
}
|
||||
for path, expected := range storageChecks {
|
||||
result, err := yq.Get(configFile, path)
|
||||
result, err := yq.Get(loomioConfigFile, path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get %s: %v", path, err)
|
||||
}
|
||||
@@ -316,10 +322,10 @@ namespace: loomio
|
||||
}
|
||||
}
|
||||
|
||||
// Verify app files were created
|
||||
appDestDir := filepath.Join(instancePath, "apps", appName)
|
||||
// Verify app package files were created at new path
|
||||
appDestDir := filepath.Join(instancePath, "data", "apps", appName)
|
||||
if !storage.FileExists(appDestDir) {
|
||||
t.Errorf("App directory was not created")
|
||||
t.Errorf("App directory was not created at %s", appDestDir)
|
||||
}
|
||||
|
||||
// Check the compiled kustomization.yaml exists
|
||||
@@ -473,31 +479,32 @@ func TestSecretTemplateProcessing(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "testapp"
|
||||
|
||||
// Setup directory structure
|
||||
// Setup directory structure (new XDG format)
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config", "apps", appName), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create instance config with database settings
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
instanceConfig := `cloud:
|
||||
domain: example.com
|
||||
apps:
|
||||
testapp:
|
||||
db:
|
||||
host: postgres.local
|
||||
port: 5432
|
||||
name: testdb
|
||||
user: testuser
|
||||
// Create instance config (cloud section only)
|
||||
instanceConfigFile := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
if err := os.WriteFile(instanceConfigFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create per-app config with database settings
|
||||
appPreConfigFile := filepath.Join(instancePath, "config", "apps", appName, "config.yaml")
|
||||
appPreConfig := `db:
|
||||
host: postgres.local
|
||||
port: 5432
|
||||
name: testdb
|
||||
user: testuser
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(instanceConfig), 0644); err != nil {
|
||||
if err := os.WriteFile(appPreConfigFile, []byte(appPreConfig), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create secrets file
|
||||
secretsFile := filepath.Join(instancePath, "secrets.yaml")
|
||||
if err := os.WriteFile(secretsFile, []byte("{}"), 0600); err != nil {
|
||||
// Create instance secrets file
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "config", "secrets.yaml"), []byte("{}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -560,11 +567,12 @@ namespace: testapp
|
||||
t.Fatalf("Failed to add app: %v", err)
|
||||
}
|
||||
|
||||
// Verify secrets were generated correctly
|
||||
// Verify secrets were generated correctly — per-app secrets file has root-level keys
|
||||
secretsMgr := secrets.NewManager()
|
||||
perAppSecretsFile := filepath.Join(instancePath, "config", "apps", appName, "secrets.yaml")
|
||||
|
||||
// Check dbPassword is random
|
||||
dbPassword, err := secretsMgr.GetSecret(secretsFile, "apps.testapp.dbPassword")
|
||||
dbPassword, err := secretsMgr.GetSecret(perAppSecretsFile, "dbPassword")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get dbPassword: %v", err)
|
||||
}
|
||||
@@ -576,7 +584,7 @@ namespace: testapp
|
||||
}
|
||||
|
||||
// Check dbUrl was constructed correctly
|
||||
dbUrl, err := secretsMgr.GetSecret(secretsFile, "apps.testapp.dbUrl")
|
||||
dbUrl, err := secretsMgr.GetSecret(perAppSecretsFile, "dbUrl")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get dbUrl: %v", err)
|
||||
}
|
||||
@@ -586,7 +594,7 @@ namespace: testapp
|
||||
}
|
||||
|
||||
// Check apiKey has prefix
|
||||
apiKey, err := secretsMgr.GetSecret(secretsFile, "apps.testapp.apiKey")
|
||||
apiKey, err := secretsMgr.GetSecret(perAppSecretsFile, "apiKey")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get apiKey: %v", err)
|
||||
}
|
||||
@@ -598,7 +606,7 @@ namespace: testapp
|
||||
}
|
||||
|
||||
// Check noDefault got a random value
|
||||
noDefault, err := secretsMgr.GetSecret(secretsFile, "apps.testapp.noDefault")
|
||||
noDefault, err := secretsMgr.GetSecret(perAppSecretsFile, "noDefault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get noDefault: %v", err)
|
||||
}
|
||||
@@ -623,21 +631,20 @@ func TestSecretTemplateWithMultipleRandoms(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "testapp"
|
||||
|
||||
// Setup directory structure
|
||||
// Setup directory structure (new XDG format)
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create minimal config
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
// Create instance config
|
||||
instanceConfigFile := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
if err := os.WriteFile(instanceConfigFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create secrets file
|
||||
secretsFile := filepath.Join(instancePath, "secrets.yaml")
|
||||
if err := os.WriteFile(secretsFile, []byte("{}"), 0600); err != nil {
|
||||
// Create instance secrets file
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "config", "secrets.yaml"), []byte("{}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -681,9 +688,10 @@ func TestSecretTemplateWithMultipleRandoms(t *testing.T) {
|
||||
t.Fatalf("Failed to add app: %v", err)
|
||||
}
|
||||
|
||||
// Verify the secret has two different random values
|
||||
// Verify the secret has two different random values — per-app secrets file has root-level keys
|
||||
secretsMgr := secrets.NewManager()
|
||||
multiRandom, err := secretsMgr.GetSecret(secretsFile, "apps.testapp.multiRandom")
|
||||
perAppSecretsFile := filepath.Join(instancePath, "config", "apps", appName, "secrets.yaml")
|
||||
multiRandom, err := secretsMgr.GetSecret(perAppSecretsFile, "multiRandom")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get multiRandom: %v", err)
|
||||
}
|
||||
@@ -715,27 +723,26 @@ func TestIntegratedTemplateProcessing(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "integrated"
|
||||
|
||||
// Setup directory structure
|
||||
// Setup directory structure (new XDG format)
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create instance config
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
// Create instance config (cloud section only)
|
||||
instanceConfigFile := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
instanceConfig := `cloud:
|
||||
domain: example.com
|
||||
smtp:
|
||||
host: mail.example.com
|
||||
port: "587"
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(instanceConfig), 0644); err != nil {
|
||||
if err := os.WriteFile(instanceConfigFile, []byte(instanceConfig), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create secrets file
|
||||
secretsFile := filepath.Join(instancePath, "secrets.yaml")
|
||||
if err := os.WriteFile(secretsFile, []byte("{}"), 0600); err != nil {
|
||||
// Create instance secrets file
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "config", "secrets.yaml"), []byte("{}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -829,11 +836,12 @@ resources:
|
||||
t.Fatalf("Failed to add app: %v", err)
|
||||
}
|
||||
|
||||
// Verify config was processed correctly
|
||||
// Verify config was processed correctly — per-app config file has root-level keys
|
||||
yq := tools.NewYQ()
|
||||
integratedConfigFile := filepath.Join(instancePath, "config", "apps", appName, "config.yaml")
|
||||
|
||||
// Check that defaultConfig templates were processed
|
||||
domain, err := yq.Get(configFile, ".apps.integrated.domain")
|
||||
domain, err := yq.Get(integratedConfigFile, ".domain")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get domain: %v", err)
|
||||
}
|
||||
@@ -841,7 +849,7 @@ resources:
|
||||
t.Errorf("Domain incorrect: expected 'integrated.example.com', got '%s'", domain)
|
||||
}
|
||||
|
||||
dbHost, err := yq.Get(configFile, ".apps.integrated.db.host")
|
||||
dbHost, err := yq.Get(integratedConfigFile, ".db.host")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get db.host: %v", err)
|
||||
}
|
||||
@@ -849,11 +857,12 @@ resources:
|
||||
t.Errorf("db.host incorrect: expected 'postgres.example.com', got '%s'", dbHost)
|
||||
}
|
||||
|
||||
// Verify secrets were generated and templates processed
|
||||
// Verify secrets were generated — per-app secrets file has root-level keys
|
||||
secretsMgr := secrets.NewManager()
|
||||
perAppSecretsFile := filepath.Join(instancePath, "config", "apps", appName, "secrets.yaml")
|
||||
|
||||
// Check apiKey has prefix and random part
|
||||
apiKey, err := secretsMgr.GetSecret(secretsFile, "apps.integrated.apiKey")
|
||||
apiKey, err := secretsMgr.GetSecret(perAppSecretsFile, "apiKey")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get apiKey: %v", err)
|
||||
}
|
||||
@@ -865,7 +874,7 @@ resources:
|
||||
}
|
||||
|
||||
// Check dbPassword is random
|
||||
dbPassword, err := secretsMgr.GetSecret(secretsFile, "apps.integrated.dbPassword")
|
||||
dbPassword, err := secretsMgr.GetSecret(perAppSecretsFile, "dbPassword")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get dbPassword: %v", err)
|
||||
}
|
||||
@@ -874,7 +883,7 @@ resources:
|
||||
}
|
||||
|
||||
// Check connectionString was built from config and secrets
|
||||
connectionString, err := secretsMgr.GetSecret(secretsFile, "apps.integrated.connectionString")
|
||||
connectionString, err := secretsMgr.GetSecret(perAppSecretsFile, "connectionString")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get connectionString: %v", err)
|
||||
}
|
||||
@@ -884,7 +893,7 @@ resources:
|
||||
}
|
||||
|
||||
// Check webhookUrl was built from processed config and secrets
|
||||
webhookUrl, err := secretsMgr.GetSecret(secretsFile, "apps.integrated.webhookUrl")
|
||||
webhookUrl, err := secretsMgr.GetSecret(perAppSecretsFile, "webhookUrl")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get webhookUrl: %v", err)
|
||||
}
|
||||
@@ -893,8 +902,8 @@ resources:
|
||||
t.Errorf("webhookUrl incorrect: expected '%s', got '%s'", expectedWebhookUrl, webhookUrl)
|
||||
}
|
||||
|
||||
// Verify deployment.yaml was processed correctly
|
||||
deploymentPath := filepath.Join(instancePath, "apps", appName, "deployment.yaml")
|
||||
// Verify deployment.yaml was processed correctly (now in data/apps/)
|
||||
deploymentPath := filepath.Join(instancePath, "data", "apps", appName, "deployment.yaml")
|
||||
deploymentData, err := os.ReadFile(deploymentPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read deployment.yaml: %v", err)
|
||||
@@ -923,25 +932,28 @@ func TestExistingSecretsNotOverwritten(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "testapp"
|
||||
|
||||
// Setup directory structure
|
||||
// Setup directory structure (new XDG format)
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config", "apps", appName), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create minimal config
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
// Create instance config
|
||||
instanceConfigFile := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
if err := os.WriteFile(instanceConfigFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create secrets file with existing secret
|
||||
secretsFile := filepath.Join(instancePath, "secrets.yaml")
|
||||
existingSecrets := `apps:
|
||||
testapp:
|
||||
existingSecret: "should-not-change"
|
||||
// Create instance secrets file
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "config", "secrets.yaml"), []byte("{}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create per-app secrets file with existing secret (root-level key in new format)
|
||||
perAppSecretsFile := filepath.Join(instancePath, "config", "apps", appName, "secrets.yaml")
|
||||
existingSecrets := `existingSecret: "should-not-change"
|
||||
`
|
||||
if err := os.WriteFile(secretsFile, []byte(existingSecrets), 0600); err != nil {
|
||||
if err := os.WriteFile(perAppSecretsFile, []byte(existingSecrets), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -989,9 +1001,9 @@ func TestExistingSecretsNotOverwritten(t *testing.T) {
|
||||
t.Fatalf("Failed to add app: %v", err)
|
||||
}
|
||||
|
||||
// Verify existing secret was NOT overwritten
|
||||
// Verify existing secret was NOT overwritten (root-level key in per-app secrets file)
|
||||
secretsMgr := secrets.NewManager()
|
||||
existingSecret, err := secretsMgr.GetSecret(secretsFile, "apps.testapp.existingSecret")
|
||||
existingSecret, err := secretsMgr.GetSecret(perAppSecretsFile, "existingSecret")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get existingSecret: %v", err)
|
||||
}
|
||||
@@ -999,8 +1011,8 @@ func TestExistingSecretsNotOverwritten(t *testing.T) {
|
||||
t.Errorf("Existing secret was overwritten: got '%s', expected 'should-not-change'", existingSecret)
|
||||
}
|
||||
|
||||
// Verify new secret was created
|
||||
newSecret, err := secretsMgr.GetSecret(secretsFile, "apps.testapp.newSecret")
|
||||
// Verify new secret was created (root-level key in per-app secrets file)
|
||||
newSecret, err := secretsMgr.GetSecret(perAppSecretsFile, "newSecret")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get newSecret: %v", err)
|
||||
}
|
||||
@@ -1033,21 +1045,20 @@ func TestDefaultConfigWithAppReferences(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "redis"
|
||||
|
||||
// Setup directory structure
|
||||
// Setup directory structure (new XDG format)
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create minimal config
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
// Create instance config
|
||||
instanceConfigFile := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
if err := os.WriteFile(instanceConfigFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create secrets file
|
||||
secretsFile := filepath.Join(instancePath, "secrets.yaml")
|
||||
if err := os.WriteFile(secretsFile, []byte(""), 0600); err != nil {
|
||||
// Create instance secrets file
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "config", "secrets.yaml"), []byte(""), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1085,9 +1096,10 @@ defaultSecrets:
|
||||
t.Fatalf("Failed to add app: %v", err)
|
||||
}
|
||||
|
||||
// Verify that the uri was processed correctly
|
||||
// Verify that the uri was processed correctly — per-app config has root-level keys
|
||||
yq := tools.NewYQ()
|
||||
uri, err := yq.Get(configFile, ".apps.redis.uri")
|
||||
redisConfigFile := filepath.Join(instancePath, "config", "apps", appName, "config.yaml")
|
||||
uri, err := yq.Get(redisConfigFile, ".uri")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get uri from config: %v", err)
|
||||
}
|
||||
@@ -1195,27 +1207,27 @@ func TestUpdateOperation(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "updateapp"
|
||||
|
||||
// Setup directory structure
|
||||
// Setup directory structure (new XDG format)
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config", "apps", appName), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create config with existing app config
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
initialConfig := `cloud:
|
||||
domain: example.com
|
||||
apps:
|
||||
updateapp:
|
||||
customValue: "should-be-preserved"
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(initialConfig), 0644); err != nil {
|
||||
// Create instance config
|
||||
instanceConfigFile := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
if err := os.WriteFile(instanceConfigFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create secrets file
|
||||
secretsFile := filepath.Join(instancePath, "secrets.yaml")
|
||||
if err := os.WriteFile(secretsFile, []byte(""), 0600); err != nil {
|
||||
// Create instance secrets file
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "config", "secrets.yaml"), []byte(""), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Pre-create per-app config with custom value that should be preserved
|
||||
updateappConfigFile := filepath.Join(instancePath, "config", "apps", appName, "config.yaml")
|
||||
if err := os.WriteFile(updateappConfigFile, []byte(`customValue: "should-be-preserved"
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1262,17 +1274,17 @@ defaultConfig:
|
||||
t.Fatalf("Failed to update app: %v", err)
|
||||
}
|
||||
|
||||
// Verify custom config was preserved
|
||||
// Verify custom config was preserved — per-app config has root-level keys
|
||||
yq := tools.NewYQ()
|
||||
customValue, _ := yq.Get(configFile, ".apps.updateapp.customValue")
|
||||
customValue, _ := yq.Get(updateappConfigFile, ".customValue")
|
||||
if customValue != "should-be-preserved" {
|
||||
t.Errorf("Custom config should be preserved during update")
|
||||
t.Errorf("Custom config should be preserved during update, got: %s", customValue)
|
||||
}
|
||||
|
||||
// Verify new field was added
|
||||
newField, _ := yq.Get(configFile, ".apps.updateapp.newField")
|
||||
newField, _ := yq.Get(updateappConfigFile, ".newField")
|
||||
if newField != "added-in-update" {
|
||||
t.Errorf("New fields should be added during update")
|
||||
t.Errorf("New fields should be added during update, got: %s", newField)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1291,14 +1303,11 @@ func TestEjectOperation(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "ejectapp"
|
||||
|
||||
// Setup directory structure
|
||||
// Setup directory structure (new XDG format — app package lives in data/apps/)
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
appInstanceDir := filepath.Join(instancePath, "apps", appName)
|
||||
appInstanceDir := filepath.Join(instancePath, "data", "apps", appName)
|
||||
packageDir := filepath.Join(appInstanceDir, ".package")
|
||||
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := storage.EnsureDir(appInstanceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1306,13 +1315,7 @@ func TestEjectOperation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create config
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create app manifest with source
|
||||
// Create app manifest with source (at new data/apps/ path)
|
||||
manifestYAML := `name: ejectapp
|
||||
description: App to test eject
|
||||
version: 1.0.0
|
||||
@@ -1449,20 +1452,21 @@ func TestResolveNamespace(t *testing.T) {
|
||||
dataDir := tmpDir
|
||||
instanceName := "test-instance"
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "apps"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
// Create per-app config dirs used by subtests
|
||||
for _, app := range []string{"metallb", "coredns", "myapp"} {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config", "apps", app), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
mgr := NewManager(dataDir, "")
|
||||
|
||||
t.Run("config.yaml has namespace", func(t *testing.T) {
|
||||
configContent := `apps:
|
||||
metallb:
|
||||
namespace: metallb-system
|
||||
ipAddressPool: "192.168.1.240-192.168.1.250"
|
||||
// Per-app config has root-level namespace key
|
||||
metallbConfigFile := filepath.Join(instancePath, "config", "apps", "metallb", "config.yaml")
|
||||
configContent := `namespace: metallb-system
|
||||
ipAddressPool: "192.168.1.240-192.168.1.250"
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
|
||||
if err := os.WriteFile(metallbConfigFile, []byte(configContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ns := mgr.ResolveNamespace(instanceName, "metallb")
|
||||
@@ -1472,16 +1476,13 @@ func TestResolveNamespace(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("config.yaml missing, manifest has namespace", func(t *testing.T) {
|
||||
// Write config without namespace for this app
|
||||
configContent := `apps:
|
||||
coredns:
|
||||
image: coredns:latest
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
|
||||
// Per-app config has no namespace key
|
||||
corednsConfigFile := filepath.Join(instancePath, "config", "apps", "coredns", "config.yaml")
|
||||
if err := os.WriteFile(corednsConfigFile, []byte("image: coredns:latest\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create manifest with top-level namespace (legacy)
|
||||
appDir := filepath.Join(instancePath, "apps", "coredns")
|
||||
// Create manifest at new data/apps/ path with top-level namespace field
|
||||
appDir := filepath.Join(instancePath, "data", "apps", "coredns")
|
||||
if err := storage.EnsureDir(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1500,11 +1501,9 @@ version: 1.0.0
|
||||
})
|
||||
|
||||
t.Run("neither config nor manifest has namespace", func(t *testing.T) {
|
||||
configContent := `apps:
|
||||
myapp:
|
||||
image: myapp:latest
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
|
||||
// Per-app config has no namespace key
|
||||
myappConfigFile := filepath.Join(instancePath, "config", "apps", "myapp", "config.yaml")
|
||||
if err := os.WriteFile(myappConfigFile, []byte("image: myapp:latest\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ns := mgr.ResolveNamespace(instanceName, "myapp")
|
||||
@@ -1514,11 +1513,9 @@ version: 1.0.0
|
||||
})
|
||||
|
||||
t.Run("config.yaml namespace takes priority over manifest", func(t *testing.T) {
|
||||
configContent := `apps:
|
||||
coredns:
|
||||
namespace: custom-namespace
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
|
||||
// Per-app config has namespace key that overrides manifest
|
||||
corednsConfigFile := filepath.Join(instancePath, "config", "apps", "coredns", "config.yaml")
|
||||
if err := os.WriteFile(corednsConfigFile, []byte("namespace: custom-namespace\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Manifest still says kube-system from the earlier subtest
|
||||
@@ -1614,16 +1611,16 @@ func TestAddWithSubdirectories(t *testing.T) {
|
||||
appName := "metallb"
|
||||
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configFile := filepath.Join(instancePath, "config.yaml")
|
||||
configFile := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
secretsFile := filepath.Join(instancePath, "secrets.yaml")
|
||||
secretsFile := filepath.Join(instancePath, "config", "secrets.yaml")
|
||||
if err := os.WriteFile(secretsFile, []byte("{}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1673,7 +1670,7 @@ defaultConfig:
|
||||
}
|
||||
|
||||
// Verify subdirectories were copied to .package/
|
||||
packageDir := filepath.Join(instancePath, "apps", appName, ".package")
|
||||
packageDir := filepath.Join(instancePath, "data", "apps", appName, ".package")
|
||||
|
||||
installFile := filepath.Join(packageDir, "installation", "metallb.yaml")
|
||||
if !storage.FileExists(installFile) {
|
||||
|
||||
@@ -623,7 +623,7 @@ func (m *Manager) detectStrategies(manifest *apps.AppManifest) []Strategy {
|
||||
|
||||
// loadAppManifest loads the app manifest from the instance directory
|
||||
func (m *Manager) loadAppManifest(instanceName, appName string) (*apps.AppManifest, error) {
|
||||
manifestPath := filepath.Join(m.dataDir, "instances", instanceName, "apps", appName, "manifest.yaml")
|
||||
manifestPath := filepath.Join(tools.GetAppPackagePath(m.dataDir, instanceName, appName), "manifest.yaml")
|
||||
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestBackupApp(t *testing.T) {
|
||||
appName := "test-app"
|
||||
|
||||
instanceDir := filepath.Join(tempDir, "instances", instanceName)
|
||||
appsDir := filepath.Join(instanceDir, "apps", appName)
|
||||
appsDir := filepath.Join(instanceDir, "data", "apps", appName)
|
||||
backupsDir := filepath.Join(instanceDir, "backups")
|
||||
|
||||
require.NoError(t, os.MkdirAll(appsDir, 0755))
|
||||
@@ -172,7 +172,9 @@ backup:
|
||||
local:
|
||||
path: ` + backupsDir + `
|
||||
`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte(configContent), 0644))
|
||||
configDir := filepath.Join(instanceDir, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "instance.yaml"), []byte(configContent), 0644))
|
||||
|
||||
// Create manager with mock strategies
|
||||
mgr := NewManager(tempDir)
|
||||
@@ -217,7 +219,7 @@ func TestBackupAppCreatesRecoveryPlan(t *testing.T) {
|
||||
appName := "test-app"
|
||||
|
||||
instanceDir := filepath.Join(tempDir, "instances", instanceName)
|
||||
appsDir := filepath.Join(instanceDir, "apps", appName)
|
||||
appsDir := filepath.Join(instanceDir, "data", "apps", appName)
|
||||
backupsDir := filepath.Join(instanceDir, "backups")
|
||||
|
||||
require.NoError(t, os.MkdirAll(appsDir, 0755))
|
||||
@@ -239,7 +241,9 @@ backup:
|
||||
local:
|
||||
path: ` + backupsDir + `
|
||||
`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte(configContent), 0644))
|
||||
configDir := filepath.Join(instanceDir, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "instance.yaml"), []byte(configContent), 0644))
|
||||
|
||||
mgr := NewManager(tempDir)
|
||||
mgr.strategies = map[string]Strategy{
|
||||
@@ -249,8 +253,8 @@ backup:
|
||||
plan, err := mgr.BackupApp(instanceName, appName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify plan was saved as YAML
|
||||
planFile := filepath.Join(backupsDir, appName, plan.Timestamp, "recovery-plan.yaml")
|
||||
// Verify plan was saved as YAML (saved in data/backup/records/{appName}/{timestamp}/)
|
||||
planFile := filepath.Join(instanceDir, "data", "backup", "records", appName, plan.Timestamp, "recovery-plan.yaml")
|
||||
_, err = os.Stat(planFile)
|
||||
assert.NoError(t, err, "recovery-plan.yaml should exist")
|
||||
|
||||
@@ -271,7 +275,7 @@ func TestListBackups(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "test-app"
|
||||
|
||||
backupsDir := filepath.Join(tempDir, "instances", instanceName, "backups", appName)
|
||||
backupsDir := filepath.Join(tempDir, "instances", instanceName, "data", "backup", "records", appName)
|
||||
|
||||
timestamps := []string{
|
||||
"20240101T120000Z",
|
||||
@@ -312,7 +316,7 @@ func TestDeleteAppBackup(t *testing.T) {
|
||||
appName := "test-app"
|
||||
timestamp := "20240101T120000Z"
|
||||
|
||||
backupDir := filepath.Join(tempDir, "instances", instanceName, "backups", appName, timestamp)
|
||||
backupDir := filepath.Join(tempDir, "instances", instanceName, "data", "backup", "records", appName, timestamp)
|
||||
require.NoError(t, os.MkdirAll(backupDir, 0755))
|
||||
|
||||
plan := &btypes.RecoveryPlan{
|
||||
@@ -335,8 +339,8 @@ func TestDeleteAppBackup(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "recovery-plan.yaml"), data, 0600))
|
||||
|
||||
// Create config
|
||||
configPath := filepath.Join(tempDir, "instances", instanceName, "config.yaml")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755))
|
||||
configDir := filepath.Join(tempDir, "instances", instanceName, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
configContent := `
|
||||
backup:
|
||||
destination:
|
||||
@@ -344,7 +348,7 @@ backup:
|
||||
local:
|
||||
path: /tmp/backups
|
||||
`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "instance.yaml"), []byte(configContent), 0644))
|
||||
|
||||
mgr := NewManager(tempDir)
|
||||
err := mgr.DeleteAppBackup(instanceName, appName, timestamp)
|
||||
@@ -405,7 +409,7 @@ func TestProgressCallback(t *testing.T) {
|
||||
instanceName := "test-instance"
|
||||
appName := "test-app"
|
||||
instanceDir := filepath.Join(tempDir, "instances", instanceName)
|
||||
appsDir := filepath.Join(instanceDir, "apps", appName)
|
||||
appsDir := filepath.Join(instanceDir, "data", "apps", appName)
|
||||
backupsDir := filepath.Join(instanceDir, "backups")
|
||||
|
||||
require.NoError(t, os.MkdirAll(appsDir, 0755))
|
||||
@@ -427,7 +431,9 @@ backup:
|
||||
local:
|
||||
path: ` + backupsDir + `
|
||||
`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte(configContent), 0644))
|
||||
configDir := filepath.Join(instanceDir, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "instance.yaml"), []byte(configContent), 0644))
|
||||
|
||||
mgr := NewManagerWithProgress(tempDir, callback)
|
||||
mgr.strategies = map[string]Strategy{
|
||||
@@ -466,7 +472,7 @@ func TestVerifyBackup(t *testing.T) {
|
||||
appName := "test-app"
|
||||
timestamp := "20240101T120000Z"
|
||||
|
||||
backupDir := filepath.Join(tempDir, "instances", instanceName, "backups", appName, timestamp)
|
||||
backupDir := filepath.Join(tempDir, "instances", instanceName, "data", "backup", "records", appName, timestamp)
|
||||
require.NoError(t, os.MkdirAll(backupDir, 0755))
|
||||
|
||||
plan := &btypes.RecoveryPlan{
|
||||
@@ -496,8 +502,8 @@ func TestVerifyBackup(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "recovery-plan.yaml"), data, 0600))
|
||||
|
||||
// Create config
|
||||
configPath := filepath.Join(tempDir, "instances", instanceName, "config.yaml")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755))
|
||||
configDir := filepath.Join(tempDir, "instances", instanceName, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
configContent := `
|
||||
backup:
|
||||
destination:
|
||||
@@ -505,7 +511,7 @@ backup:
|
||||
local:
|
||||
path: /tmp/backups
|
||||
`
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "instance.yaml"), []byte(configContent), 0644))
|
||||
|
||||
mgr := NewManager(tempDir)
|
||||
|
||||
@@ -550,7 +556,7 @@ func TestRestoreAppInPlace(t *testing.T) {
|
||||
timestamp := "20240101T120000Z"
|
||||
|
||||
instanceDir := filepath.Join(tempDir, "instances", instanceName)
|
||||
backupDir := filepath.Join(instanceDir, "backups", appName, timestamp)
|
||||
backupDir := filepath.Join(instanceDir, "data", "backup", "records", appName, timestamp)
|
||||
require.NoError(t, os.MkdirAll(backupDir, 0755))
|
||||
|
||||
// Config with backup destination
|
||||
@@ -561,7 +567,9 @@ backup:
|
||||
local:
|
||||
path: ` + filepath.Join(instanceDir, "backups") + `
|
||||
`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte(configContent), 0644))
|
||||
configDir := filepath.Join(instanceDir, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "instance.yaml"), []byte(configContent), 0644))
|
||||
|
||||
// Saved plan in backed_up status
|
||||
plan := &btypes.RecoveryPlan{
|
||||
@@ -603,7 +611,7 @@ func TestSwitchAppInPlaceUpdatesActiveDeployment(t *testing.T) {
|
||||
timestamp := "20240101T120000Z"
|
||||
|
||||
instanceDir := filepath.Join(tempDir, "instances", instanceName)
|
||||
backupDir := filepath.Join(instanceDir, "backups", appName, timestamp)
|
||||
backupDir := filepath.Join(instanceDir, "data", "backup", "records", appName, timestamp)
|
||||
require.NoError(t, os.MkdirAll(backupDir, 0755))
|
||||
|
||||
// Config with backup destination and existing app config
|
||||
@@ -617,7 +625,9 @@ backup:
|
||||
local:
|
||||
path: ` + filepath.Join(instanceDir, "backups") + `
|
||||
`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte(configContent), 0644))
|
||||
configDir := filepath.Join(instanceDir, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "instance.yaml"), []byte(configContent), 0644))
|
||||
|
||||
// Saved plan in restored state with in-place mode
|
||||
plan := &btypes.RecoveryPlan{
|
||||
@@ -657,7 +667,7 @@ func TestCleanupAppInPlaceSkipsNamespaceDeletion(t *testing.T) {
|
||||
timestamp := "20240101T120000Z"
|
||||
|
||||
instanceDir := filepath.Join(tempDir, "instances", instanceName)
|
||||
backupDir := filepath.Join(instanceDir, "backups", appName, timestamp)
|
||||
backupDir := filepath.Join(instanceDir, "data", "backup", "records", appName, timestamp)
|
||||
require.NoError(t, os.MkdirAll(backupDir, 0755))
|
||||
|
||||
configContent := `
|
||||
@@ -667,7 +677,9 @@ backup:
|
||||
local:
|
||||
path: ` + filepath.Join(instanceDir, "backups") + `
|
||||
`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte(configContent), 0644))
|
||||
configDir := filepath.Join(instanceDir, "config")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "instance.yaml"), []byte(configContent), 0644))
|
||||
|
||||
// Saved plan in switched state
|
||||
plan := &btypes.RecoveryPlan{
|
||||
|
||||
@@ -21,16 +21,18 @@ func TestBackupClusterConfig(t *testing.T) {
|
||||
backupsDir := filepath.Join(instanceDir, "backups")
|
||||
|
||||
require.NoError(t, os.MkdirAll(backupsDir, 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "talos", "generated"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "config"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "data", "talos"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "data"), 0755))
|
||||
|
||||
// Create cluster config files
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "kubeconfig"), []byte("kubeconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "secrets.yaml"), []byte("secrets-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "talos", "generated", "talosconfig"), []byte("talosconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "talos", "generated", "controlplane.yaml"), []byte("controlplane-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "talos", "generated", "worker.yaml"), []byte("worker-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "talos", "generated", "secrets.yaml"), []byte("talos-secrets-data"), 0644))
|
||||
// Create cluster config files at new XDG paths
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "kubeconfig"), []byte("kubeconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config", "instance.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config", "secrets.yaml"), []byte("secrets-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "talos", "talosconfig"), []byte("talosconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "talos", "controlplane.yaml"), []byte("controlplane-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "talos", "worker.yaml"), []byte("worker-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "talos", "pki.yaml"), []byte("talos-pki-data"), 0644))
|
||||
|
||||
mgr := NewManager(tempDir)
|
||||
plan, err := mgr.BackupClusterConfig(instanceName)
|
||||
@@ -49,8 +51,8 @@ func TestBackupClusterConfig(t *testing.T) {
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 7, files)
|
||||
|
||||
// Verify plan was saved to disk
|
||||
planFile := filepath.Join(backupsDir, "_cluster", plan.Timestamp, "recovery-plan.yaml")
|
||||
// Verify plan was saved to disk (new path: data/backup/records)
|
||||
planFile := filepath.Join(instanceDir, "data", "backup", "records", "_cluster", plan.Timestamp, "recovery-plan.yaml")
|
||||
_, err = os.Stat(planFile)
|
||||
assert.NoError(t, err, "recovery-plan.yaml should exist")
|
||||
}
|
||||
@@ -63,10 +65,12 @@ func TestBackupClusterConfigSkipsMissingFiles(t *testing.T) {
|
||||
backupsDir := filepath.Join(instanceDir, "backups")
|
||||
|
||||
require.NoError(t, os.MkdirAll(backupsDir, 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "config"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "data"), 0755))
|
||||
|
||||
// Only create kubeconfig and config.yaml (no talos files)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "kubeconfig"), []byte("kubeconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
// Only create kubeconfig and config/instance.yaml (no talos files)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "kubeconfig"), []byte("kubeconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config", "instance.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
|
||||
mgr := NewManager(tempDir)
|
||||
plan, err := mgr.BackupClusterConfig(instanceName)
|
||||
@@ -87,16 +91,17 @@ func TestBackupClusterConfigFailsWithNoFiles(t *testing.T) {
|
||||
backupsDir := filepath.Join(instanceDir, "backups")
|
||||
|
||||
require.NoError(t, os.MkdirAll(backupsDir, 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "config"), 0755))
|
||||
|
||||
// Create only config.yaml for backup destination config, but none of the cluster files
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
// Create only config/instance.yaml for backup destination config, but none of the other cluster files
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config", "instance.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
|
||||
mgr := NewManager(tempDir)
|
||||
_, err := mgr.BackupClusterConfig(instanceName)
|
||||
// config.yaml itself is one of the files, so it will be found
|
||||
// To truly have zero files, we need to remove config.yaml too,
|
||||
// config/instance.yaml itself is one of the files, so it will be found
|
||||
// To truly have zero files, we need to remove config/instance.yaml too,
|
||||
// but then loadDestination fails first. So this test verifies
|
||||
// that config.yaml IS included in the backup.
|
||||
// that config/instance.yaml IS included in the backup.
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -108,11 +113,12 @@ func TestBackupClusterConfigArchiveContents(t *testing.T) {
|
||||
backupsDir := filepath.Join(instanceDir, "backups")
|
||||
|
||||
require.NoError(t, os.MkdirAll(backupsDir, 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "talos", "generated"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "config"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "data", "talos"), 0755))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "kubeconfig"), []byte("kubeconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "talos", "generated", "talosconfig"), []byte("talosconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "kubeconfig"), []byte("kubeconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config", "instance.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "talos", "talosconfig"), []byte("talosconfig-data"), 0644))
|
||||
|
||||
mgr := NewManager(tempDir)
|
||||
plan, err := mgr.BackupClusterConfig(instanceName)
|
||||
@@ -143,9 +149,9 @@ func TestBackupClusterConfigArchiveContents(t *testing.T) {
|
||||
fileNames = append(fileNames, header.Name)
|
||||
}
|
||||
|
||||
assert.Contains(t, fileNames, "kubeconfig")
|
||||
assert.Contains(t, fileNames, "config.yaml")
|
||||
assert.Contains(t, fileNames, filepath.Join("talos", "generated", "talosconfig"))
|
||||
assert.Contains(t, fileNames, filepath.Join("data", "kubeconfig"))
|
||||
assert.Contains(t, fileNames, filepath.Join("config", "instance.yaml"))
|
||||
assert.Contains(t, fileNames, filepath.Join("data", "talos", "talosconfig"))
|
||||
}
|
||||
|
||||
func TestBackupClusterConfigListAndDelete(t *testing.T) {
|
||||
@@ -156,9 +162,11 @@ func TestBackupClusterConfigListAndDelete(t *testing.T) {
|
||||
backupsDir := filepath.Join(instanceDir, "backups")
|
||||
|
||||
require.NoError(t, os.MkdirAll(backupsDir, 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "config"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(instanceDir, "data"), 0755))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "kubeconfig"), []byte("kubeconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "data", "kubeconfig"), []byte("kubeconfig-data"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(instanceDir, "config", "instance.yaml"), []byte("backup:\n destination:\n type: local\n local:\n path: "+backupsDir+"\n"), 0644))
|
||||
|
||||
mgr := NewManager(tempDir)
|
||||
|
||||
|
||||
@@ -14,15 +14,9 @@ import (
|
||||
)
|
||||
|
||||
// LoadInstanceBackupConfig loads backup destination and retention configuration.
|
||||
// Reads from config/instance.yaml in the new format, config.yaml in legacy.
|
||||
// Schedules are managed separately via per-app files in config/apps/{app}/schedule.yaml.
|
||||
func LoadInstanceBackupConfig(dataDir, instanceName string) (*BackupConfiguration, error) {
|
||||
var configPath string
|
||||
if tools.IsNewInstanceFormat(dataDir, instanceName) {
|
||||
configPath = tools.GetInstanceYamlPath(dataDir, instanceName)
|
||||
} else {
|
||||
configPath = tools.GetInstanceConfigPath(dataDir, instanceName)
|
||||
}
|
||||
configPath := tools.GetInstanceYamlPath(dataDir, instanceName)
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
@@ -82,14 +76,8 @@ func LoadInstanceBackupConfig(dataDir, instanceName string) (*BackupConfiguratio
|
||||
}
|
||||
|
||||
// SaveInstanceBackupConfig writes the destination and retention sections of backup config.
|
||||
// Writes to config/instance.yaml in new format, config.yaml in legacy.
|
||||
func SaveInstanceBackupConfig(dataDir, instanceName string, dest *DestinationConfig, retention *RetentionPolicy) error {
|
||||
var configPath string
|
||||
if tools.IsNewInstanceFormat(dataDir, instanceName) {
|
||||
configPath = tools.GetInstanceYamlPath(dataDir, instanceName)
|
||||
} else {
|
||||
configPath = tools.GetInstanceConfigPath(dataDir, instanceName)
|
||||
}
|
||||
configPath := tools.GetInstanceYamlPath(dataDir, instanceName)
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
btypes "github.com/wild-cloud/wild-central/daemon/internal/backup/types"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// MigrateSchedules extracts backup.schedules[] from the legacy config.yaml into
|
||||
// per-app config/apps/{app}/schedule.yaml (operator config) and
|
||||
// cache/schedules/{app}.yaml (daemon state) files.
|
||||
//
|
||||
// It is idempotent: if backup.schedules is absent or empty in config.yaml, it
|
||||
// returns nil immediately. On success it removes backup.schedules from config.yaml.
|
||||
//
|
||||
// Collision handling: when two entries share the same targetName+frequency
|
||||
// (e.g. race-condition duplicates), the entry with the later nextRun is kept and
|
||||
// the collision is logged for operator review.
|
||||
func MigrateSchedules(dataDir, instanceName string) error {
|
||||
configPath := tools.GetInstanceConfigPath(dataDir, instanceName)
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var typed struct {
|
||||
Backup *struct {
|
||||
Schedules []btypes.BackupSchedule `yaml:"schedules"`
|
||||
} `yaml:"backup"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &typed); err != nil {
|
||||
return fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if typed.Backup == nil || len(typed.Backup.Schedules) == 0 {
|
||||
return nil // nothing to migrate
|
||||
}
|
||||
|
||||
schedules := typed.Backup.Schedules
|
||||
|
||||
// Deduplicate: group by targetName+frequency, keep highest nextRun.
|
||||
type key struct{ app, tier string }
|
||||
best := make(map[key]*btypes.BackupSchedule)
|
||||
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.Frequency != "daily" && s.Frequency != "weekly" && s.Frequency != "monthly" {
|
||||
slog.Warn("migrate: skipping schedule with unknown frequency",
|
||||
"id", s.ID, "frequency", s.Frequency)
|
||||
continue
|
||||
}
|
||||
k := key{s.TargetName, s.Frequency}
|
||||
existing := best[k]
|
||||
if existing == nil {
|
||||
best[k] = s
|
||||
continue
|
||||
}
|
||||
keepNew := s.NextRun != nil && (existing.NextRun == nil || s.NextRun.After(*existing.NextRun))
|
||||
if keepNew {
|
||||
slog.Warn("migrate: duplicate schedule, keeping newer nextRun",
|
||||
"kept", s.ID, "dropped", existing.ID,
|
||||
"app", s.TargetName, "tier", s.Frequency)
|
||||
best[k] = s
|
||||
} else {
|
||||
slog.Warn("migrate: duplicate schedule, keeping existing nextRun",
|
||||
"kept", existing.ID, "dropped", s.ID,
|
||||
"app", s.TargetName, "tier", s.Frequency)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect per-app config and state.
|
||||
type appData struct {
|
||||
config *btypes.AppScheduleConfig
|
||||
state *btypes.AppScheduleState
|
||||
}
|
||||
apps := make(map[string]*appData)
|
||||
|
||||
for k, s := range best {
|
||||
if apps[k.app] == nil {
|
||||
apps[k.app] = &appData{
|
||||
config: &btypes.AppScheduleConfig{},
|
||||
state: &btypes.AppScheduleState{},
|
||||
}
|
||||
}
|
||||
d := apps[k.app]
|
||||
|
||||
tc := &btypes.ScheduleTierConfig{
|
||||
Enabled: s.Enabled,
|
||||
Time: s.Time,
|
||||
DayOfWeek: s.DayOfWeek,
|
||||
DayOfMonth: s.DayOfMonth,
|
||||
Retention: s.Retention,
|
||||
}
|
||||
SetTierConfig(d.config, k.tier, tc)
|
||||
|
||||
if s.LastRun != nil || s.NextRun != nil {
|
||||
ts := &btypes.TierState{LastRun: s.LastRun, NextRun: s.NextRun}
|
||||
switch k.tier {
|
||||
case "daily":
|
||||
d.state.Daily = ts
|
||||
case "weekly":
|
||||
d.state.Weekly = ts
|
||||
case "monthly":
|
||||
d.state.Monthly = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for appName, d := range apps {
|
||||
if err := SaveAppScheduleConfig(dataDir, instanceName, appName, d.config); err != nil {
|
||||
return fmt.Errorf("write schedule config for %s: %w", appName, err)
|
||||
}
|
||||
hasState := d.state.Daily != nil || d.state.Weekly != nil || d.state.Monthly != nil
|
||||
if hasState {
|
||||
if err := SaveScheduleCache(dataDir, instanceName, appName, d.state); err != nil {
|
||||
return fmt.Errorf("write schedule cache for %s: %w", appName, err)
|
||||
}
|
||||
}
|
||||
slog.Info("migrate: wrote schedule files", "instance", instanceName, "app", appName)
|
||||
}
|
||||
|
||||
// Strip backup.schedules from config.yaml.
|
||||
var root map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||
return fmt.Errorf("reparse config: %w", err)
|
||||
}
|
||||
|
||||
if backupSection, ok := root["backup"].(map[string]interface{}); ok {
|
||||
delete(backupSection, "schedules")
|
||||
}
|
||||
|
||||
out, err := yaml.Marshal(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, out, 0644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("migrate: schedule migration complete",
|
||||
"instance", instanceName, "apps", len(apps))
|
||||
return nil
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestMigrateSchedules_Basic(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
instanceName := "test-instance"
|
||||
instanceDir := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := os.MkdirAll(instanceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 5, 20, 2, 0, 0, 0, time.UTC)
|
||||
next := time.Date(2026, 5, 21, 2, 0, 0, 0, time.UTC)
|
||||
|
||||
configYAML := `cloud:
|
||||
domain: test.local
|
||||
backup:
|
||||
destination:
|
||||
type: local
|
||||
schedules:
|
||||
- id: s_gitea_daily
|
||||
name: gitea daily
|
||||
targetType: app
|
||||
targetName: gitea
|
||||
frequency: daily
|
||||
time: "02:00"
|
||||
enabled: true
|
||||
lastRun: ` + now.Format(time.RFC3339) + `
|
||||
nextRun: ` + next.Format(time.RFC3339) + `
|
||||
createdAt: ` + now.Format(time.RFC3339) + `
|
||||
updatedAt: ` + now.Format(time.RFC3339) + `
|
||||
- id: s_gitea_weekly
|
||||
name: gitea weekly
|
||||
targetType: app
|
||||
targetName: gitea
|
||||
frequency: weekly
|
||||
time: "03:00"
|
||||
dayOfWeek: 0
|
||||
enabled: true
|
||||
createdAt: ` + now.Format(time.RFC3339) + `
|
||||
updatedAt: ` + now.Format(time.RFC3339) + `
|
||||
- id: s_discourse_daily
|
||||
name: discourse daily
|
||||
targetType: app
|
||||
targetName: discourse
|
||||
frequency: daily
|
||||
time: "02:00"
|
||||
enabled: true
|
||||
createdAt: ` + now.Format(time.RFC3339) + `
|
||||
updatedAt: ` + now.Format(time.RFC3339)
|
||||
|
||||
configPath := tools.GetInstanceConfigPath(dataDir, instanceName)
|
||||
if err := os.WriteFile(configPath, []byte(configYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := MigrateSchedules(dataDir, instanceName); err != nil {
|
||||
t.Fatalf("MigrateSchedules() error = %v", err)
|
||||
}
|
||||
|
||||
// Check gitea schedule config
|
||||
giteaConfig, err := LoadAppScheduleConfig(dataDir, instanceName, "gitea")
|
||||
if err != nil || giteaConfig == nil {
|
||||
t.Fatalf("LoadAppScheduleConfig(gitea) = %v, %v", giteaConfig, err)
|
||||
}
|
||||
if giteaConfig.Daily == nil || giteaConfig.Daily.Time != "02:00" {
|
||||
t.Errorf("gitea daily time = %v, want 02:00", giteaConfig.Daily)
|
||||
}
|
||||
if giteaConfig.Weekly == nil || giteaConfig.Weekly.DayOfWeek != 0 {
|
||||
t.Errorf("gitea weekly dayOfWeek = %v, want 0", giteaConfig.Weekly)
|
||||
}
|
||||
|
||||
// Check gitea schedule cache (lastRun/nextRun)
|
||||
giteaState, err := LoadScheduleCache(dataDir, instanceName, "gitea")
|
||||
if err != nil || giteaState == nil {
|
||||
t.Fatalf("LoadScheduleCache(gitea) = %v, %v", giteaState, err)
|
||||
}
|
||||
if giteaState.Daily == nil || giteaState.Daily.LastRun == nil {
|
||||
t.Error("gitea daily lastRun is nil")
|
||||
} else if !giteaState.Daily.LastRun.Equal(now) {
|
||||
t.Errorf("gitea daily lastRun = %v, want %v", giteaState.Daily.LastRun, now)
|
||||
}
|
||||
|
||||
// Check discourse
|
||||
discConfig, err := LoadAppScheduleConfig(dataDir, instanceName, "discourse")
|
||||
if err != nil || discConfig == nil {
|
||||
t.Fatalf("LoadAppScheduleConfig(discourse) = %v, %v", discConfig, err)
|
||||
}
|
||||
if discConfig.Daily == nil {
|
||||
t.Error("discourse daily is nil")
|
||||
}
|
||||
|
||||
// Check that backup.schedules is removed from config.yaml
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if backupSection, ok := root["backup"].(map[string]interface{}); ok {
|
||||
if _, hasSchedules := backupSection["schedules"]; hasSchedules {
|
||||
t.Error("backup.schedules was not removed from config.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
// Check cloud.domain is preserved
|
||||
if cloud, ok := root["cloud"].(map[string]interface{}); !ok || cloud["domain"] != "test.local" {
|
||||
t.Errorf("cloud.domain not preserved: %v", root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchedules_Idempotent(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
instanceName := "test-instance"
|
||||
instanceDir := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := os.MkdirAll(instanceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configYAML := "cloud:\n domain: test.local\nbackup:\n destination:\n type: local\n"
|
||||
configPath := tools.GetInstanceConfigPath(dataDir, instanceName)
|
||||
if err := os.WriteFile(configPath, []byte(configYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := MigrateSchedules(dataDir, instanceName); err != nil {
|
||||
t.Fatalf("MigrateSchedules() on empty schedules error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchedules_DuplicateFrequency(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
instanceName := "test-instance"
|
||||
instanceDir := filepath.Join(dataDir, "instances", instanceName)
|
||||
if err := os.MkdirAll(instanceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
old := time.Date(2026, 5, 20, 2, 0, 0, 0, time.UTC)
|
||||
newer := time.Date(2026, 5, 21, 2, 0, 0, 0, time.UTC)
|
||||
|
||||
configYAML := `backup:
|
||||
schedules:
|
||||
- id: s_gitea_daily
|
||||
targetType: app
|
||||
targetName: gitea
|
||||
frequency: daily
|
||||
time: "02:00"
|
||||
enabled: true
|
||||
nextRun: ` + old.Format(time.RFC3339) + `
|
||||
createdAt: ` + old.Format(time.RFC3339) + `
|
||||
updatedAt: ` + old.Format(time.RFC3339) + `
|
||||
- id: s_gitea_daily_collision
|
||||
targetType: app
|
||||
targetName: gitea
|
||||
frequency: daily
|
||||
time: "03:00"
|
||||
enabled: true
|
||||
nextRun: ` + newer.Format(time.RFC3339) + `
|
||||
createdAt: ` + old.Format(time.RFC3339) + `
|
||||
updatedAt: ` + old.Format(time.RFC3339)
|
||||
|
||||
configPath := tools.GetInstanceConfigPath(dataDir, instanceName)
|
||||
if err := os.WriteFile(configPath, []byte(configYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := MigrateSchedules(dataDir, instanceName); err != nil {
|
||||
t.Fatalf("MigrateSchedules() error = %v", err)
|
||||
}
|
||||
|
||||
// The entry with the later nextRun (03:00) should win.
|
||||
config, err := LoadAppScheduleConfig(dataDir, instanceName, "gitea")
|
||||
if err != nil || config == nil || config.Daily == nil {
|
||||
t.Fatalf("LoadAppScheduleConfig(gitea) = %v, %v", config, err)
|
||||
}
|
||||
if config.Daily.Time != "03:00" {
|
||||
t.Errorf("collision: kept time %q, want 03:00 (higher nextRun)", config.Daily.Time)
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func TestEnforceRetention(t *testing.T) {
|
||||
|
||||
// Create instance config with local destination
|
||||
instanceDir := filepath.Join(tmpDir, "instances", instanceName)
|
||||
if err := os.MkdirAll(instanceDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(instanceDir, "config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -152,11 +152,11 @@ func TestEnforceRetention(t *testing.T) {
|
||||
},
|
||||
}
|
||||
configData, _ := yaml.Marshal(config)
|
||||
if err := os.WriteFile(filepath.Join(instanceDir, "config.yaml"), configData, 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(instanceDir, "config", "instance.yaml"), configData, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(instanceDir, "backups", appName)
|
||||
backupDir := filepath.Join(instanceDir, "data", "backup", "records", appName)
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TestEnforceRetentionSkipsActiveBackups(t *testing.T) {
|
||||
appName := "test-app"
|
||||
|
||||
instanceDir := filepath.Join(tmpDir, "instances", instanceName)
|
||||
if err := os.MkdirAll(instanceDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(instanceDir, "config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -237,11 +237,11 @@ func TestEnforceRetentionSkipsActiveBackups(t *testing.T) {
|
||||
},
|
||||
}
|
||||
configData, _ := yaml.Marshal(config)
|
||||
if err := os.WriteFile(filepath.Join(instanceDir, "config.yaml"), configData, 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(instanceDir, "config", "instance.yaml"), configData, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(instanceDir, "backups", appName)
|
||||
backupDir := filepath.Join(instanceDir, "data", "backup", "records", appName)
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func TestEnforceRetentionKeepDaysPreservesRecent(t *testing.T) {
|
||||
appName := "test-app"
|
||||
|
||||
instanceDir := filepath.Join(tmpDir, "instances", instanceName)
|
||||
if err := os.MkdirAll(instanceDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(instanceDir, "config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -316,11 +316,11 @@ func TestEnforceRetentionKeepDaysPreservesRecent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
configData, _ := yaml.Marshal(config)
|
||||
if err := os.WriteFile(filepath.Join(instanceDir, "config.yaml"), configData, 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(instanceDir, "config", "instance.yaml"), configData, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(instanceDir, "backups", appName)
|
||||
backupDir := filepath.Join(instanceDir, "data", "backup", "records", appName)
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -437,67 +437,56 @@ func (p *PostgreSQLStrategy) backupGlobals(kubeconfigPath string, dest btypes.Ba
|
||||
return <-errChan
|
||||
}
|
||||
|
||||
// getDatabaseName determines the database name for the app
|
||||
// getDatabaseName determines the database name for the app from its per-app config file.
|
||||
func (p *PostgreSQLStrategy) getDatabaseName(instanceName, appName string) string {
|
||||
configPath := tools.GetInstanceConfigPath(p.dataDir, instanceName)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
appConfig := p.readAppConfig(instanceName, appName)
|
||||
if appConfig == nil {
|
||||
return appName
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return appName
|
||||
if dbName, ok := appConfig["dbName"].(string); ok && dbName != "" {
|
||||
return dbName
|
||||
}
|
||||
|
||||
if apps, ok := config["apps"].(map[string]interface{}); ok {
|
||||
if appConfig, ok := apps[appName].(map[string]interface{}); ok {
|
||||
if dbName, ok := appConfig["dbName"].(string); ok && dbName != "" {
|
||||
return dbName
|
||||
}
|
||||
if db, ok := appConfig["db"].(map[string]interface{}); ok {
|
||||
if dbName, ok := db["name"].(string); ok && dbName != "" {
|
||||
return dbName
|
||||
}
|
||||
}
|
||||
if db, ok := appConfig["db"].(map[string]interface{}); ok {
|
||||
if dbName, ok := db["name"].(string); ok && dbName != "" {
|
||||
return dbName
|
||||
}
|
||||
}
|
||||
|
||||
return appName
|
||||
}
|
||||
|
||||
// getAppUser retrieves the database user for the app from config
|
||||
// getAppUser retrieves the database user for the app from its per-app config file.
|
||||
func (p *PostgreSQLStrategy) getAppUser(instanceName, appName string) string {
|
||||
configPath := tools.GetInstanceConfigPath(p.dataDir, instanceName)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if apps, ok := config["apps"].(map[string]interface{}); ok {
|
||||
if appConfig, ok := apps[appName].(map[string]interface{}); ok {
|
||||
if dbUser, ok := appConfig["dbUser"].(string); ok && dbUser != "" {
|
||||
appConfig := p.readAppConfig(instanceName, appName)
|
||||
if appConfig != nil {
|
||||
if dbUser, ok := appConfig["dbUser"].(string); ok && dbUser != "" {
|
||||
return dbUser
|
||||
}
|
||||
if dbUsername, ok := appConfig["dbUsername"].(string); ok && dbUsername != "" {
|
||||
return dbUsername
|
||||
}
|
||||
if db, ok := appConfig["db"].(map[string]interface{}); ok {
|
||||
if dbUser, ok := db["user"].(string); ok && dbUser != "" {
|
||||
return dbUser
|
||||
}
|
||||
if dbUsername, ok := appConfig["dbUsername"].(string); ok && dbUsername != "" {
|
||||
return dbUsername
|
||||
}
|
||||
if db, ok := appConfig["db"].(map[string]interface{}); ok {
|
||||
if dbUser, ok := db["user"].(string); ok && dbUser != "" {
|
||||
return dbUser
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return appName
|
||||
}
|
||||
|
||||
// readAppConfig reads the per-app config file and returns its contents.
|
||||
func (p *PostgreSQLStrategy) readAppConfig(instanceName, appName string) map[string]interface{} {
|
||||
configPath := tools.GetAppConfigPath(p.dataDir, instanceName, appName)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// databaseExists returns true if the named Postgres database exists
|
||||
func (p *PostgreSQLStrategy) databaseExists(kubeconfigPath, podName, dbName string) bool {
|
||||
cmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -164,6 +165,16 @@ func TestPostgreSQLStrategy_Verify(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeAppConfig writes a per-app config file at config/apps/{appName}/config.yaml.
|
||||
func writeAppConfig(t *testing.T, tmpDir, instanceName, appName, config string) {
|
||||
t.Helper()
|
||||
appConfigDir := filepath.Join(tmpDir, "instances", instanceName, "config", "apps", appName)
|
||||
err := os.MkdirAll(appConfigDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(appConfigDir, "config.yaml"), []byte(config), 0644)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPostgreSQLStrategy_GetDatabaseName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -172,50 +183,40 @@ func TestPostgreSQLStrategy_GetDatabaseName(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "flat dbName key",
|
||||
config: `apps:
|
||||
myapp:
|
||||
dbName: my_database
|
||||
`,
|
||||
name: "flat dbName key",
|
||||
config: "dbName: my_database\n",
|
||||
appName: "myapp",
|
||||
expected: "my_database",
|
||||
},
|
||||
{
|
||||
name: "nested db.name key",
|
||||
config: `apps:
|
||||
e2e-test-app:
|
||||
namespace: e2e-test-app
|
||||
db:
|
||||
host: postgres
|
||||
name: e2e_test_app
|
||||
user: e2e_test_app
|
||||
config: `namespace: e2e-test-app
|
||||
db:
|
||||
host: postgres
|
||||
name: e2e_test_app
|
||||
user: e2e_test_app
|
||||
`,
|
||||
appName: "e2e-test-app",
|
||||
expected: "e2e_test_app",
|
||||
},
|
||||
{
|
||||
name: "flat key takes precedence over nested",
|
||||
config: `apps:
|
||||
myapp:
|
||||
dbName: flat_name
|
||||
db:
|
||||
name: nested_name
|
||||
config: `dbName: flat_name
|
||||
db:
|
||||
name: nested_name
|
||||
`,
|
||||
appName: "myapp",
|
||||
expected: "flat_name",
|
||||
},
|
||||
{
|
||||
name: "no config falls back to appName",
|
||||
config: `apps:
|
||||
myapp:
|
||||
namespace: myapp
|
||||
`,
|
||||
name: "no config falls back to appName",
|
||||
config: "namespace: myapp\n",
|
||||
appName: "myapp",
|
||||
expected: "myapp",
|
||||
},
|
||||
{
|
||||
name: "missing app falls back to appName",
|
||||
config: `apps: {}`,
|
||||
config: "",
|
||||
appName: "missing-app",
|
||||
expected: "missing-app",
|
||||
},
|
||||
@@ -224,12 +225,9 @@ func TestPostgreSQLStrategy_GetDatabaseName(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
instanceDir := tmpDir + "/instances/test-instance"
|
||||
err := os.MkdirAll(instanceDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(instanceDir+"/config.yaml", []byte(tt.config), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
if tt.config != "" {
|
||||
writeAppConfig(t, tmpDir, "test-instance", tt.appName, tt.config)
|
||||
}
|
||||
s := &PostgreSQLStrategy{dataDir: tmpDir}
|
||||
result := s.getDatabaseName("test-instance", tt.appName)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
@@ -245,53 +243,40 @@ func TestPostgreSQLStrategy_GetAppUser(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "flat dbUser key",
|
||||
config: `apps:
|
||||
myapp:
|
||||
dbUser: my_user
|
||||
`,
|
||||
name: "flat dbUser key",
|
||||
config: "dbUser: my_user\n",
|
||||
appName: "myapp",
|
||||
expected: "my_user",
|
||||
},
|
||||
{
|
||||
name: "flat dbUsername key",
|
||||
config: `apps:
|
||||
myapp:
|
||||
dbUsername: my_username
|
||||
`,
|
||||
name: "flat dbUsername key",
|
||||
config: "dbUsername: my_username\n",
|
||||
appName: "myapp",
|
||||
expected: "my_username",
|
||||
},
|
||||
{
|
||||
name: "nested db.user key",
|
||||
config: `apps:
|
||||
e2e-test-app:
|
||||
namespace: e2e-test-app
|
||||
db:
|
||||
host: postgres
|
||||
name: e2e_test_app
|
||||
user: e2e_test_app
|
||||
config: `namespace: e2e-test-app
|
||||
db:
|
||||
host: postgres
|
||||
name: e2e_test_app
|
||||
user: e2e_test_app
|
||||
`,
|
||||
appName: "e2e-test-app",
|
||||
expected: "e2e_test_app",
|
||||
},
|
||||
{
|
||||
name: "flat key takes precedence over nested",
|
||||
config: `apps:
|
||||
myapp:
|
||||
dbUser: flat_user
|
||||
db:
|
||||
user: nested_user
|
||||
config: `dbUser: flat_user
|
||||
db:
|
||||
user: nested_user
|
||||
`,
|
||||
appName: "myapp",
|
||||
expected: "flat_user",
|
||||
},
|
||||
{
|
||||
name: "no user config falls back to appName",
|
||||
config: `apps:
|
||||
myapp:
|
||||
namespace: myapp
|
||||
`,
|
||||
name: "no user config falls back to appName",
|
||||
config: "namespace: myapp\n",
|
||||
appName: "myapp",
|
||||
expected: "myapp",
|
||||
},
|
||||
@@ -300,12 +285,7 @@ func TestPostgreSQLStrategy_GetAppUser(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
instanceDir := tmpDir + "/instances/test-instance"
|
||||
err := os.MkdirAll(instanceDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(instanceDir+"/config.yaml", []byte(tt.config), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
writeAppConfig(t, tmpDir, "test-instance", tt.appName, tt.config)
|
||||
s := &PostgreSQLStrategy{dataDir: tmpDir}
|
||||
result := s.getAppUser("test-instance", tt.appName)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
|
||||
@@ -11,11 +11,57 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/operations"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/storage"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
||||
)
|
||||
|
||||
// nodeConfig is the subset of config/nodes/{hostname}.yaml fields needed by the cluster package.
|
||||
type nodeConfig struct {
|
||||
Role string `yaml:"role"`
|
||||
TargetIP string `yaml:"targetIp"`
|
||||
}
|
||||
|
||||
// readNodeConfig reads config/nodes/{hostname}.yaml and returns the parsed config.
|
||||
func readNodeConfig(dataDir, instanceName, hostname string) (*nodeConfig, error) {
|
||||
path := tools.GetNodeConfigPath(dataDir, instanceName, hostname)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read node config %s: %w", hostname, err)
|
||||
}
|
||||
var nc nodeConfig
|
||||
if err := yaml.Unmarshal(data, &nc); err != nil {
|
||||
return nil, fmt.Errorf("parse node config %s: %w", hostname, err)
|
||||
}
|
||||
return &nc, nil
|
||||
}
|
||||
|
||||
// controlPlaneIPs returns the targetIp of every controlplane node in config/nodes/.
|
||||
func controlPlaneIPs(dataDir, instanceName string) ([]string, error) {
|
||||
nodeDir := tools.GetNodeConfigDirPath(dataDir, instanceName)
|
||||
entries, err := os.ReadDir(nodeDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ips []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
||||
continue
|
||||
}
|
||||
hostname := strings.TrimSuffix(entry.Name(), ".yaml")
|
||||
nc, err := readNodeConfig(dataDir, instanceName, hostname)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if nc.Role == "controlplane" && nc.TargetIP != "" {
|
||||
ips = append(ips, nc.TargetIP)
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// Manager handles cluster lifecycle operations
|
||||
type Manager struct {
|
||||
dataDir string
|
||||
@@ -59,47 +105,34 @@ type ClusterStatus struct {
|
||||
NodeStatuses map[string]NodeStatus `json:"nodeStatuses,omitempty"`
|
||||
}
|
||||
|
||||
// GetTalosDir returns the talos directory for an instance
|
||||
func (m *Manager) GetTalosDir(instanceName string) string {
|
||||
return tools.GetInstanceTalosPath(m.dataDir, instanceName)
|
||||
}
|
||||
|
||||
// GetGeneratedDir returns the generated config directory
|
||||
func (m *Manager) GetGeneratedDir(instanceName string) string {
|
||||
return filepath.Join(m.GetTalosDir(instanceName), "generated")
|
||||
}
|
||||
|
||||
// GenerateConfig generates initial cluster configuration using talosctl gen config
|
||||
func (m *Manager) GenerateConfig(instanceName string, config *ClusterConfig) error {
|
||||
generatedDir := m.GetGeneratedDir(instanceName)
|
||||
talosDir := tools.GetTalosDirPath(m.dataDir, instanceName)
|
||||
pkiPath := tools.GetTalosPkiPath(m.dataDir, instanceName)
|
||||
|
||||
// Check if already generated (idempotency)
|
||||
secretsFile := filepath.Join(generatedDir, "secrets.yaml")
|
||||
if storage.FileExists(secretsFile) {
|
||||
// Already generated
|
||||
if storage.FileExists(pkiPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("generating cluster config", "component", "cluster", "instance", instanceName, "cluster", config.ClusterName, "vip", config.VIP)
|
||||
|
||||
// Ensure generated directory exists
|
||||
if err := storage.EnsureDir(generatedDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create generated directory: %w", err)
|
||||
if err := storage.EnsureDir(talosDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create talos directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate secrets
|
||||
secretsPath := filepath.Join(generatedDir, "secrets.yaml")
|
||||
cmd := exec.Command("talosctl", "gen", "secrets", "--output-file", secretsPath)
|
||||
// Generate secrets → data/talos/pki.yaml
|
||||
cmd := exec.Command("talosctl", "gen", "secrets", "--output-file", pkiPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate secrets: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
// Generate config with secrets
|
||||
// Generate config → data/talos/{talosconfig,controlplane.yaml,worker.yaml}
|
||||
endpoint := fmt.Sprintf("https://%s:6443", config.VIP)
|
||||
cmd = exec.Command("talosctl", "gen", "config",
|
||||
"--with-secrets", secretsPath,
|
||||
"--output-dir", generatedDir,
|
||||
"--with-secrets", pkiPath,
|
||||
"--output-dir", talosDir,
|
||||
config.ClusterName,
|
||||
endpoint,
|
||||
)
|
||||
@@ -135,26 +168,24 @@ func (m *Manager) Bootstrap(instanceName, nodeName string) (string, error) {
|
||||
// runBootstrapWithTracking runs the bootstrap process with detailed progress tracking
|
||||
func (m *Manager) runBootstrapWithTracking(instanceName, nodeName, opID string) error {
|
||||
ctx := context.Background()
|
||||
configPath := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
yq := tools.NewYQ()
|
||||
|
||||
// Get node's target IP
|
||||
nodeIPRaw, err := yq.Get(configPath, fmt.Sprintf(".cluster.nodes.active.%s.targetIp", nodeName))
|
||||
// Get node's target IP from config/nodes/{nodeName}.yaml
|
||||
nc, err := readNodeConfig(m.dataDir, instanceName, nodeName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get node IP: %w", err)
|
||||
return fmt.Errorf("failed to get node config: %w", err)
|
||||
}
|
||||
|
||||
nodeIP := tools.CleanYQOutput(nodeIPRaw)
|
||||
if nodeIP == "" || nodeIP == "null" {
|
||||
if nc.TargetIP == "" {
|
||||
return fmt.Errorf("node %s does not have a target IP configured", nodeName)
|
||||
}
|
||||
nodeIP := nc.TargetIP
|
||||
|
||||
// Get VIP
|
||||
// Get VIP from config/instance.yaml
|
||||
configPath := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
yq := tools.NewYQ()
|
||||
vipRaw, err := yq.Get(configPath, ".cluster.nodes.control.vip")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get VIP: %w", err)
|
||||
}
|
||||
|
||||
vip := tools.CleanYQOutput(vipRaw)
|
||||
if vip == "" || vip == "null" {
|
||||
return fmt.Errorf("control plane VIP not configured")
|
||||
@@ -435,6 +466,63 @@ func (m *Manager) RegenerateKubeconfig(instanceName string) error {
|
||||
return m.retrieveKubeconfigFromCluster(instanceName, vip, 30*time.Second)
|
||||
}
|
||||
|
||||
// RenewTalosconfig regenerates the talosconfig client certificate from the existing PKI root.
|
||||
// Use this when the talosconfig cert has expired (1-year validity).
|
||||
func (m *Manager) RenewTalosconfig(instanceName string) error {
|
||||
configPath := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
yq := tools.NewYQ()
|
||||
|
||||
vipRaw, err := yq.Get(configPath, ".cluster.nodes.control.vip")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get VIP: %w", err)
|
||||
}
|
||||
vip := tools.CleanYQOutput(vipRaw)
|
||||
if vip == "" || vip == "null" {
|
||||
return fmt.Errorf("control plane VIP not configured")
|
||||
}
|
||||
|
||||
clusterNameRaw, err := yq.Get(configPath, ".cluster.name")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get cluster name: %w", err)
|
||||
}
|
||||
clusterName := tools.CleanYQOutput(clusterNameRaw)
|
||||
if clusterName == "" || clusterName == "null" {
|
||||
clusterName = instanceName
|
||||
}
|
||||
|
||||
pkiPath := tools.GetTalosPkiPath(m.dataDir, instanceName)
|
||||
if !storage.FileExists(pkiPath) {
|
||||
return fmt.Errorf("PKI file not found at %s — cluster has not been initialised", pkiPath)
|
||||
}
|
||||
|
||||
talosconfigPath := tools.GetTalosconfigPath(m.dataDir, instanceName)
|
||||
endpoint := fmt.Sprintf("https://%s:6443", vip)
|
||||
|
||||
slog.Info("renewing talosconfig", "component", "cluster", "instance", instanceName, "vip", vip)
|
||||
|
||||
cmd := exec.Command("talosctl", "gen", "config",
|
||||
"--with-secrets", pkiPath,
|
||||
"--output-types", "talosconfig",
|
||||
"--output", talosconfigPath,
|
||||
"--force",
|
||||
clusterName,
|
||||
endpoint,
|
||||
)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("talosctl gen config failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
// Populate endpoint (gen config leaves it empty)
|
||||
cmdEndpoint := exec.Command("talosctl", "config", "endpoint", vip)
|
||||
tools.WithTalosconfig(cmdEndpoint, talosconfigPath)
|
||||
if output, err := cmdEndpoint.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to set endpoint: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
slog.Info("talosconfig renewed", "component", "cluster", "instance", instanceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigureEndpoints updates talosconfig to use VIP and retrieves kubeconfig
|
||||
func (m *Manager) ConfigureEndpoints(instanceName string, includeNodes bool) error {
|
||||
slog.Info("configuring cluster endpoints", "component", "cluster", "instance", instanceName, "includeNodes", includeNodes)
|
||||
@@ -460,14 +548,10 @@ func (m *Manager) ConfigureEndpoints(instanceName string, includeNodes bool) err
|
||||
|
||||
// Add control node IPs if requested
|
||||
if includeNodes {
|
||||
nodesRaw, err := yq.Exec("eval", ".cluster.nodes.active | to_entries | .[] | select(.value.role == \"controlplane\") | .value.targetIp", configPath)
|
||||
if err == nil {
|
||||
nodeIPs := strings.Split(strings.TrimSpace(string(nodesRaw)), "\n")
|
||||
for _, ip := range nodeIPs {
|
||||
ip = tools.CleanYQOutput(ip)
|
||||
if ip != "" && ip != "null" && ip != vip {
|
||||
endpoints = append(endpoints, ip)
|
||||
}
|
||||
nodeIPs, _ := controlPlaneIPs(m.dataDir, instanceName)
|
||||
for _, ip := range nodeIPs {
|
||||
if ip != vip {
|
||||
endpoints = append(endpoints, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -725,9 +809,9 @@ func (m *Manager) Reset(instanceName string, confirm bool) error {
|
||||
// 2. Remove generated configs
|
||||
// 3. Clear node status in config.yaml
|
||||
|
||||
generatedDir := m.GetGeneratedDir(instanceName)
|
||||
if storage.FileExists(generatedDir) {
|
||||
if err := os.RemoveAll(generatedDir); err != nil {
|
||||
talosDir := tools.GetTalosDirPath(m.dataDir, instanceName)
|
||||
if storage.FileExists(talosDir) {
|
||||
if err := os.RemoveAll(talosDir); err != nil {
|
||||
return fmt.Errorf("failed to remove generated configs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,14 +170,9 @@ func DeepMerge(dst, src map[string]interface{}) map[string]interface{} {
|
||||
// instance config on top. Returns the merged result as an untyped map suitable
|
||||
// for passing to gomplate as template context.
|
||||
// If the global config is missing, returns the instance config alone.
|
||||
// In new-format instances, assembles apps.* from config/apps/*/config.yaml files.
|
||||
// Assembles apps.* from config/apps/*/config.yaml files.
|
||||
func LoadMergedInstanceConfig(dataDir, instanceName string) (map[string]interface{}, error) {
|
||||
var instanceConfigPath string
|
||||
if tools.IsNewInstanceFormat(dataDir, instanceName) {
|
||||
instanceConfigPath = tools.GetInstanceYamlPath(dataDir, instanceName)
|
||||
} else {
|
||||
instanceConfigPath = filepath.Join(dataDir, "instances", instanceName, "config.yaml")
|
||||
}
|
||||
instanceConfigPath := tools.GetInstanceYamlPath(dataDir, instanceName)
|
||||
globalConfigPath := filepath.Join(dataDir, "config.yaml")
|
||||
|
||||
instanceData, err := os.ReadFile(instanceConfigPath)
|
||||
@@ -193,29 +188,27 @@ func LoadMergedInstanceConfig(dataDir, instanceName string) (map[string]interfac
|
||||
instanceMap = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// In new format, assemble apps.* map from per-app config files
|
||||
if tools.IsNewInstanceFormat(dataDir, instanceName) {
|
||||
appsConfigDir := filepath.Join(dataDir, "instances", instanceName, "config", "apps")
|
||||
if entries, err := os.ReadDir(appsConfigDir); err == nil {
|
||||
appsMap := make(map[string]interface{})
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
appName := entry.Name()
|
||||
appConfigPath := tools.GetAppConfigPath(dataDir, instanceName, appName)
|
||||
appData, err := os.ReadFile(appConfigPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var appConfig map[string]interface{}
|
||||
if err := yaml.Unmarshal(appData, &appConfig); err == nil && appConfig != nil {
|
||||
appsMap[appName] = appConfig
|
||||
}
|
||||
// Assemble apps.* map from per-app config files
|
||||
appsConfigDir := filepath.Join(dataDir, "instances", instanceName, "config", "apps")
|
||||
if entries, err := os.ReadDir(appsConfigDir); err == nil {
|
||||
appsMap := make(map[string]interface{})
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if len(appsMap) > 0 {
|
||||
instanceMap["apps"] = appsMap
|
||||
appName := entry.Name()
|
||||
appConfigPath := tools.GetAppConfigPath(dataDir, instanceName, appName)
|
||||
appData, err := os.ReadFile(appConfigPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var appConfig map[string]interface{}
|
||||
if err := yaml.Unmarshal(appData, &appConfig); err == nil && appConfig != nil {
|
||||
appsMap[appName] = appConfig
|
||||
}
|
||||
}
|
||||
if len(appsMap) > 0 {
|
||||
instanceMap["apps"] = appsMap
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -617,8 +617,8 @@ func TestDeepMerge(t *testing.T) {
|
||||
func TestLoadMergedInstanceConfig(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
instanceName := "test"
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
os.MkdirAll(instancePath, 0755)
|
||||
instanceConfigDir := filepath.Join(dataDir, "instances", instanceName, "config")
|
||||
os.MkdirAll(instanceConfigDir, 0755)
|
||||
|
||||
globalConfig := `operator:
|
||||
email: test@example.com
|
||||
@@ -636,7 +636,7 @@ cloud:
|
||||
`
|
||||
|
||||
os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte(globalConfig), 0644)
|
||||
os.WriteFile(filepath.Join(instancePath, "config.yaml"), []byte(instanceConfig), 0644)
|
||||
os.WriteFile(filepath.Join(instanceConfigDir, "instance.yaml"), []byte(instanceConfig), 0644)
|
||||
|
||||
merged, err := LoadMergedInstanceConfig(dataDir, instanceName)
|
||||
if err != nil {
|
||||
@@ -679,10 +679,10 @@ cloud:
|
||||
|
||||
func TestLoadMergedInstanceConfig_NoGlobalConfig(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
instancePath := filepath.Join(dataDir, "instances", "test")
|
||||
os.MkdirAll(instancePath, 0755)
|
||||
instanceConfigDir := filepath.Join(dataDir, "instances", "test", "config")
|
||||
os.MkdirAll(instanceConfigDir, 0755)
|
||||
|
||||
os.WriteFile(filepath.Join(instancePath, "config.yaml"), []byte("cluster:\n name: test\n"), 0644)
|
||||
os.WriteFile(filepath.Join(instanceConfigDir, "instance.yaml"), []byte("cluster:\n name: test\n"), 0644)
|
||||
|
||||
merged, err := LoadMergedInstanceConfig(dataDir, "test")
|
||||
if err != nil {
|
||||
|
||||
@@ -80,23 +80,42 @@ func (m *Manager) CreateInstance(name string) error {
|
||||
// Acquire lock for instance creation
|
||||
lockPath := tools.GetInstancesLockPath(m.dataDir)
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
// Create instance directory
|
||||
if err := storage.EnsureDir(instancePath, 0755); err != nil {
|
||||
return fmt.Errorf("creating instance directory: %w", err)
|
||||
// Create instance directory and config/ subdirectory
|
||||
configDir := tools.GetInstanceConfigDirPath(m.dataDir, name)
|
||||
if err := storage.EnsureDir(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating instance config directory: %w", err)
|
||||
}
|
||||
|
||||
// Create config file
|
||||
if err := m.configMgr.EnsureInstanceConfig(name, instancePath); err != nil {
|
||||
return fmt.Errorf("creating config file: %w", err)
|
||||
// Create config/instance.yaml
|
||||
configPath := tools.GetInstanceConfigPath(m.dataDir, name)
|
||||
if !storage.FileExists(configPath) {
|
||||
initialConfig := &config.InstanceConfig{}
|
||||
initialConfig.Cluster.Name = name
|
||||
initialConfig.Cluster.Nodes.Active = make(map[string]config.NodeConfig)
|
||||
initialConfig.Apps = make(map[string]interface{})
|
||||
if err := config.SaveCloudConfig(initialConfig, configPath); err != nil {
|
||||
return fmt.Errorf("creating config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create secrets file
|
||||
if err := m.secretsMgr.EnsureSecretsFile(instancePath); err != nil {
|
||||
return fmt.Errorf("creating secrets file: %w", err)
|
||||
// Create config/secrets.yaml
|
||||
secretsPath := tools.GetInstanceSecretsPath(m.dataDir, name)
|
||||
if !storage.FileExists(secretsPath) {
|
||||
initialSecrets := `# Wild Cloud Instance Secrets
|
||||
# WARNING: This file contains sensitive data. Keep secure!
|
||||
cluster:
|
||||
talosSecrets: ""
|
||||
kubeconfig: ""
|
||||
cloudflare:
|
||||
token: ""
|
||||
`
|
||||
if err := storage.WriteFile(secretsPath, []byte(initialSecrets), 0600); err != nil {
|
||||
return fmt.Errorf("creating secrets file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create subdirectories
|
||||
subdirs := []string{"talos", "k8s", "logs", "backups"}
|
||||
// Create subdirectories (talos only; k8s, logs, backups removed per XDG layout)
|
||||
subdirs := []string{"talos"}
|
||||
for _, subdir := range subdirs {
|
||||
subdirPath := filepath.Join(instancePath, subdir)
|
||||
if err := storage.EnsureDir(subdirPath, 0755); err != nil {
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestManager_CreateInstance(t *testing.T) {
|
||||
@@ -24,10 +22,8 @@ func TestManager_CreateInstance(t *testing.T) {
|
||||
instancePath := m.GetInstancePath(instanceName)
|
||||
expectedDirs := []string{
|
||||
instancePath,
|
||||
filepath.Join(instancePath, "config"),
|
||||
filepath.Join(instancePath, "talos"),
|
||||
filepath.Join(instancePath, "k8s"),
|
||||
filepath.Join(instancePath, "logs"),
|
||||
filepath.Join(instancePath, "backups"),
|
||||
}
|
||||
|
||||
for _, dir := range expectedDirs {
|
||||
@@ -177,154 +173,3 @@ func TestManager_ValidateInstance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateInstance(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
m := NewManager(tmpDir)
|
||||
instanceName := "migrate-test"
|
||||
instancePath := filepath.Join(tmpDir, "instances", instanceName)
|
||||
|
||||
// Build a minimal legacy instance directory
|
||||
legacyDirs := []string{
|
||||
filepath.Join(instancePath, "apps", "myapp"),
|
||||
filepath.Join(instancePath, "talos", "generated"),
|
||||
filepath.Join(instancePath, "backups"),
|
||||
filepath.Join(instancePath, "operations"),
|
||||
filepath.Join(instancePath, "discovery"),
|
||||
filepath.Join(instancePath, "setup", "cluster-nodes"),
|
||||
}
|
||||
for _, d := range legacyDirs {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write legacy config.yaml
|
||||
legacyConfig := map[string]interface{}{
|
||||
"operator": map[string]interface{}{"email": "test@example.com"},
|
||||
"cloud": map[string]interface{}{"domain": "example.com"},
|
||||
"cluster": map[string]interface{}{
|
||||
"name": "test",
|
||||
"nodes": map[string]interface{}{
|
||||
"active": map[string]interface{}{
|
||||
"node-1": map[string]interface{}{
|
||||
"role": "worker",
|
||||
"disk": "/dev/sda",
|
||||
"interface": "eth0",
|
||||
"targetIp": "192.168.1.10",
|
||||
"applied": true,
|
||||
"configured": true,
|
||||
"currentIp": "192.168.1.10",
|
||||
"maintenance": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"apps": map[string]interface{}{
|
||||
"myapp": map[string]interface{}{
|
||||
"namespace": "myapp",
|
||||
"domain": "myapp.example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
configData, _ := yaml.Marshal(legacyConfig)
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "config.yaml"), configData, 0644); err != nil {
|
||||
t.Fatalf("setup config.yaml: %v", err)
|
||||
}
|
||||
|
||||
// Write legacy secrets.yaml
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "secrets.yaml"), []byte("backup:\n s3Key: secret\n"), 0600); err != nil {
|
||||
t.Fatalf("setup secrets.yaml: %v", err)
|
||||
}
|
||||
|
||||
// Write legacy kubeconfig
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "kubeconfig"), []byte("apiVersion: v1\n"), 0600); err != nil {
|
||||
t.Fatalf("setup kubeconfig: %v", err)
|
||||
}
|
||||
|
||||
// Stub talos generated files
|
||||
for _, f := range []string{"secrets.yaml", "talosconfig", "controlplane.yaml", "worker.yaml"} {
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "talos", "generated", f), []byte("# stub\n"), 0644); err != nil {
|
||||
t.Fatalf("setup talos/%s: %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stub discovery status
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "discovery", "status.json"), []byte("{}"), 0644); err != nil {
|
||||
t.Fatalf("setup discovery/status.json: %v", err)
|
||||
}
|
||||
|
||||
// Run migration
|
||||
if err := m.MigrateInstance(instanceName); err != nil {
|
||||
t.Fatalf("MigrateInstance: %v", err)
|
||||
}
|
||||
|
||||
// Verify new format detected
|
||||
if !m.InstanceExists(instanceName) {
|
||||
t.Fatal("instance should still exist after migration")
|
||||
}
|
||||
|
||||
// Verify config/instance.yaml written
|
||||
instanceYaml := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
if _, err := os.Stat(instanceYaml); err != nil {
|
||||
t.Errorf("config/instance.yaml missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify per-app config written
|
||||
appConfig := filepath.Join(instancePath, "config", "apps", "myapp", "config.yaml")
|
||||
if _, err := os.Stat(appConfig); err != nil {
|
||||
t.Errorf("config/apps/myapp/config.yaml missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify per-node config written
|
||||
nodeConfig := filepath.Join(instancePath, "config", "nodes", "node-1.yaml")
|
||||
if _, err := os.Stat(nodeConfig); err != nil {
|
||||
t.Errorf("config/nodes/node-1.yaml missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify node cache written
|
||||
nodeCache := filepath.Join(instancePath, "cache", "nodes", "node-1.yaml")
|
||||
if _, err := os.Stat(nodeCache); err != nil {
|
||||
t.Errorf("cache/nodes/node-1.yaml missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify talos files moved
|
||||
if _, err := os.Stat(filepath.Join(instancePath, "data", "talos", "pki.yaml")); err != nil {
|
||||
t.Errorf("data/talos/pki.yaml missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(instancePath, "data", "talos", "talosconfig")); err != nil {
|
||||
t.Errorf("data/talos/talosconfig missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify secrets moved
|
||||
if _, err := os.Stat(filepath.Join(instancePath, "config", "secrets.yaml")); err != nil {
|
||||
t.Errorf("config/secrets.yaml missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify kubeconfig moved
|
||||
if _, err := os.Stat(filepath.Join(instancePath, "data", "kubeconfig")); err != nil {
|
||||
t.Errorf("data/kubeconfig missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify app package moved
|
||||
if _, err := os.Stat(filepath.Join(instancePath, "data", "apps", "myapp")); err != nil {
|
||||
t.Errorf("data/apps/myapp missing: %v", err)
|
||||
}
|
||||
|
||||
// Verify .gitignore written
|
||||
gitignore, err := os.ReadFile(filepath.Join(instancePath, ".gitignore"))
|
||||
if err != nil {
|
||||
t.Errorf(".gitignore missing: %v", err)
|
||||
} else if string(gitignore) != "state/\ncache/\n" {
|
||||
t.Errorf(".gitignore content wrong: %q", string(gitignore))
|
||||
}
|
||||
|
||||
// Verify legacy config.yaml removed
|
||||
if _, err := os.Stat(filepath.Join(instancePath, "config.yaml")); err == nil {
|
||||
t.Error("legacy config.yaml should have been removed")
|
||||
}
|
||||
|
||||
// Verify idempotency
|
||||
if err := m.MigrateInstance(instanceName); err != nil {
|
||||
t.Errorf("MigrateInstance not idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/storage"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// MigrateInstance migrates a legacy instance directory to the XDG-based layout.
|
||||
// It is idempotent: if the instance is already in the new format it returns nil immediately.
|
||||
//
|
||||
// The caller is responsible for calling backup.MigrateSchedules before this function,
|
||||
// so that backup.schedules have been stripped from config.yaml before instance.yaml is written.
|
||||
//
|
||||
// Migration steps:
|
||||
// 1. Create XDG directory tree (config/, data/, state/, cache/)
|
||||
// 2. Move apps/{name}/ → data/apps/{name}/
|
||||
// 3. Extract apps.{name} config blocks → config/apps/{name}/config.yaml
|
||||
// 4. Split cluster.nodes.active into config/nodes/{hostname}.yaml + cache/nodes/{hostname}.yaml
|
||||
// 5. Move talos/generated/* → data/talos/*
|
||||
// 6. Write config/instance.yaml from remaining config sections
|
||||
// 7. Move secrets.yaml → config/secrets.yaml
|
||||
// 8. Move kubeconfig → data/kubeconfig
|
||||
// 9. Write .gitignore
|
||||
// 10. Move backups/ → data/backup/records/
|
||||
// 11. Move operations/ → state/operations/
|
||||
// 12. Move discovery/status.json → cache/discovery.json
|
||||
// 13. Move setup/cluster-nodes/ → cache/nodes/setup/
|
||||
// 14. Remove unused legacy dirs (pxe/, k8s/, logs/, apps-restore/)
|
||||
// 15. Delete old config.yaml and talosconfig root symlinks
|
||||
func (m *Manager) MigrateInstance(name string) error {
|
||||
if !m.InstanceExists(name) {
|
||||
return fmt.Errorf("instance %s does not exist", name)
|
||||
}
|
||||
|
||||
// Idempotent: already migrated
|
||||
if tools.IsNewInstanceFormat(m.dataDir, name) {
|
||||
slog.Info("instance already in new format, skipping migration", "instance", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
instancePath := tools.GetInstancePath(m.dataDir, name)
|
||||
slog.Info("migrating instance to XDG layout", "instance", name)
|
||||
|
||||
// Step 1: Create XDG directory tree
|
||||
dirs := []string{
|
||||
filepath.Join(instancePath, "config", "apps"),
|
||||
filepath.Join(instancePath, "config", "nodes"),
|
||||
filepath.Join(instancePath, "config", "talos", "patches"),
|
||||
filepath.Join(instancePath, "data", "apps"),
|
||||
filepath.Join(instancePath, "data", "talos"),
|
||||
filepath.Join(instancePath, "data", "backup", "records"),
|
||||
filepath.Join(instancePath, "state", "operations"),
|
||||
filepath.Join(instancePath, "cache", "schedules"),
|
||||
filepath.Join(instancePath, "cache", "nodes"),
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := storage.EnsureDir(d, 0755); err != nil {
|
||||
return fmt.Errorf("create dir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read the (possibly already schedule-stripped) config.yaml
|
||||
legacyConfigPath := filepath.Join(instancePath, "config.yaml")
|
||||
legacyData, err := os.ReadFile(legacyConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read legacy config.yaml: %w", err)
|
||||
}
|
||||
var root map[string]interface{}
|
||||
if err := yaml.Unmarshal(legacyData, &root); err != nil {
|
||||
return fmt.Errorf("parse legacy config.yaml: %w", err)
|
||||
}
|
||||
if root == nil {
|
||||
root = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Step 2 & 3: Move apps and extract per-app config
|
||||
if err := migrateApps(instancePath, root); err != nil {
|
||||
return fmt.Errorf("migrate apps: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Split nodes into config/nodes and cache/nodes
|
||||
if err := migrateNodes(instancePath, root); err != nil {
|
||||
return fmt.Errorf("migrate nodes: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Move talos/generated/* → data/talos/*
|
||||
if err := migrateTalos(instancePath); err != nil {
|
||||
return fmt.Errorf("migrate talos: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Write config/instance.yaml (cloud + cluster infra + operator + backup dest/retention)
|
||||
if err := writeInstanceYaml(instancePath, root); err != nil {
|
||||
return fmt.Errorf("write config/instance.yaml: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Move secrets.yaml → config/secrets.yaml
|
||||
if err := moveIfExists(
|
||||
filepath.Join(instancePath, "secrets.yaml"),
|
||||
filepath.Join(instancePath, "config", "secrets.yaml"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("move secrets.yaml: %w", err)
|
||||
}
|
||||
os.Remove(filepath.Join(instancePath, "secrets.yaml.lock"))
|
||||
|
||||
// Step 8: Move kubeconfig → data/kubeconfig
|
||||
if err := moveIfExists(
|
||||
filepath.Join(instancePath, "kubeconfig"),
|
||||
filepath.Join(instancePath, "data", "kubeconfig"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("move kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
// Step 9: Write .gitignore
|
||||
gitignore := "state/\ncache/\n"
|
||||
if err := os.WriteFile(filepath.Join(instancePath, ".gitignore"), []byte(gitignore), 0644); err != nil {
|
||||
return fmt.Errorf("write .gitignore: %w", err)
|
||||
}
|
||||
|
||||
// Step 10: Move backups/ → data/backup/records/
|
||||
if err := moveDirContents(
|
||||
filepath.Join(instancePath, "backups"),
|
||||
filepath.Join(instancePath, "data", "backup", "records"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("move backups/: %w", err)
|
||||
}
|
||||
os.Remove(filepath.Join(instancePath, "backups"))
|
||||
|
||||
// Step 11: Move operations/ → state/operations/
|
||||
if err := moveDirContents(
|
||||
filepath.Join(instancePath, "operations"),
|
||||
filepath.Join(instancePath, "state", "operations"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("move operations/: %w", err)
|
||||
}
|
||||
os.Remove(filepath.Join(instancePath, "operations"))
|
||||
|
||||
// Step 12: Move discovery/status.json → cache/discovery.json
|
||||
if err := moveIfExists(
|
||||
filepath.Join(instancePath, "discovery", "status.json"),
|
||||
filepath.Join(instancePath, "cache", "discovery.json"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("move discovery/status.json: %w", err)
|
||||
}
|
||||
os.RemoveAll(filepath.Join(instancePath, "discovery"))
|
||||
|
||||
// Step 13: Move setup/cluster-nodes/ → cache/nodes/setup/
|
||||
if err := moveDir(
|
||||
filepath.Join(instancePath, "setup", "cluster-nodes"),
|
||||
filepath.Join(instancePath, "cache", "nodes", "setup"),
|
||||
); err != nil {
|
||||
slog.Warn("migrate: could not move setup/cluster-nodes", "instance", name, "error", err)
|
||||
}
|
||||
os.RemoveAll(filepath.Join(instancePath, "setup"))
|
||||
|
||||
// Step 14: Remove unused legacy dirs
|
||||
for _, d := range []string{"pxe", "k8s", "logs", "apps-restore"} {
|
||||
os.RemoveAll(filepath.Join(instancePath, d))
|
||||
}
|
||||
|
||||
// Step 15: Remove legacy config.yaml and root talosconfig
|
||||
os.Remove(legacyConfigPath)
|
||||
os.Remove(filepath.Join(instancePath, "config.yaml.lock"))
|
||||
os.Remove(filepath.Join(instancePath, "talosconfig"))
|
||||
|
||||
slog.Info("migration complete", "instance", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateApps moves apps/{name}/ → data/apps/{name}/ and extracts config to config/apps/{name}/config.yaml.
|
||||
func migrateApps(instancePath string, root map[string]interface{}) error {
|
||||
legacyAppsDir := filepath.Join(instancePath, "apps")
|
||||
if !storage.FileExists(legacyAppsDir) {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(legacyAppsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read apps dir: %w", err)
|
||||
}
|
||||
|
||||
appsConfig, _ := root["apps"].(map[string]interface{})
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
appName := entry.Name()
|
||||
|
||||
// Move app package: apps/{name}/ → data/apps/{name}/
|
||||
src := filepath.Join(legacyAppsDir, appName)
|
||||
dst := filepath.Join(instancePath, "data", "apps", appName)
|
||||
if !storage.FileExists(dst) {
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("move app %s: %w", appName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract app config → config/apps/{name}/config.yaml
|
||||
if appConfig, ok := appsConfig[appName]; ok {
|
||||
appConfigDir := filepath.Join(instancePath, "config", "apps", appName)
|
||||
if err := storage.EnsureDir(appConfigDir, 0755); err != nil {
|
||||
return fmt.Errorf("create config dir for %s: %w", appName, err)
|
||||
}
|
||||
appConfigPath := filepath.Join(appConfigDir, "config.yaml")
|
||||
if !storage.FileExists(appConfigPath) {
|
||||
out, err := yaml.Marshal(appConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config for %s: %w", appName, err)
|
||||
}
|
||||
if err := os.WriteFile(appConfigPath, out, 0644); err != nil {
|
||||
return fmt.Errorf("write config for %s: %w", appName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove legacy apps dir if empty (it should be since we moved all subdirs)
|
||||
os.Remove(legacyAppsDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateNodes splits cluster.nodes.active entries into config/nodes and cache/nodes.
|
||||
func migrateNodes(instancePath string, root map[string]interface{}) error {
|
||||
clusterSection, _ := root["cluster"].(map[string]interface{})
|
||||
if clusterSection == nil {
|
||||
return nil
|
||||
}
|
||||
nodesSection, _ := clusterSection["nodes"].(map[string]interface{})
|
||||
if nodesSection == nil {
|
||||
return nil
|
||||
}
|
||||
activeNodes, _ := nodesSection["active"].(map[string]interface{})
|
||||
if activeNodes == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for hostname, raw := range activeNodes {
|
||||
fields, _ := raw.(map[string]interface{})
|
||||
if fields == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Config fields (operator-authored)
|
||||
cfgFields := map[string]interface{}{}
|
||||
for _, k := range []string{"role", "disk", "interface", "targetIp", "schematicId", "version", "maintenance"} {
|
||||
if v, ok := fields[k]; ok {
|
||||
cfgFields[k] = v
|
||||
}
|
||||
}
|
||||
cfgPath := filepath.Join(instancePath, "config", "nodes", hostname+".yaml")
|
||||
if !storage.FileExists(cfgPath) {
|
||||
if out, err := yaml.Marshal(cfgFields); err == nil {
|
||||
os.WriteFile(cfgPath, out, 0644)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache fields (daemon-written)
|
||||
cacheFields := map[string]interface{}{}
|
||||
for _, k := range []string{"applied", "configured", "currentIp"} {
|
||||
if v, ok := fields[k]; ok {
|
||||
cacheFields[k] = v
|
||||
}
|
||||
}
|
||||
if len(cacheFields) > 0 {
|
||||
cachePath := filepath.Join(instancePath, "cache", "nodes", hostname+".yaml")
|
||||
if !storage.FileExists(cachePath) {
|
||||
if out, err := yaml.Marshal(cacheFields); err == nil {
|
||||
os.WriteFile(cachePath, out, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateTalos moves talos/generated/* to data/talos/ and optionally moves talos/patches/.
|
||||
func migrateTalos(instancePath string) error {
|
||||
genDir := filepath.Join(instancePath, "talos", "generated")
|
||||
|
||||
moves := map[string]string{
|
||||
"secrets.yaml": "data/talos/pki.yaml",
|
||||
"talosconfig": "data/talos/talosconfig",
|
||||
"controlplane.yaml": "data/talos/controlplane.yaml",
|
||||
"worker.yaml": "data/talos/worker.yaml",
|
||||
}
|
||||
for src, dstRel := range moves {
|
||||
if err := moveIfExists(
|
||||
filepath.Join(genDir, src),
|
||||
filepath.Join(instancePath, dstRel),
|
||||
); err != nil {
|
||||
slog.Warn("migrate talos: could not move file", "src", src, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Move talos/patches/ if it exists
|
||||
patchesSrc := filepath.Join(instancePath, "talos", "patches")
|
||||
patchesDst := filepath.Join(instancePath, "config", "talos", "patches")
|
||||
if storage.FileExists(patchesSrc) {
|
||||
if err := moveDirContents(patchesSrc, patchesDst); err != nil {
|
||||
slog.Warn("migrate talos: could not move patches", "error", err)
|
||||
}
|
||||
os.RemoveAll(patchesSrc)
|
||||
}
|
||||
|
||||
// Clean up legacy talos dirs
|
||||
os.RemoveAll(genDir)
|
||||
os.Remove(filepath.Join(instancePath, "talos"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeInstanceYaml assembles config/instance.yaml from cloud, cluster infra, operator, and backup sections.
|
||||
// Excludes: apps, cluster.nodes.active (already migrated), backup.schedules (migrated by MigrateSchedules).
|
||||
func writeInstanceYaml(instancePath string, root map[string]interface{}) error {
|
||||
instanceYamlPath := filepath.Join(instancePath, "config", "instance.yaml")
|
||||
if storage.FileExists(instanceYamlPath) {
|
||||
return nil // idempotent
|
||||
}
|
||||
|
||||
out := make(map[string]interface{})
|
||||
|
||||
if v, ok := root["operator"]; ok {
|
||||
out["operator"] = v
|
||||
}
|
||||
if v, ok := root["cloud"]; ok {
|
||||
out["cloud"] = v
|
||||
}
|
||||
|
||||
// Cluster: exclude nodes.active (moved to per-file)
|
||||
if clusterRaw, ok := root["cluster"]; ok {
|
||||
cluster, _ := clusterRaw.(map[string]interface{})
|
||||
if cluster != nil {
|
||||
clusterOut := make(map[string]interface{})
|
||||
for k, v := range cluster {
|
||||
if k == "nodes" {
|
||||
// Keep everything except active
|
||||
nodesRaw, _ := v.(map[string]interface{})
|
||||
if nodesRaw != nil {
|
||||
nodesOut := make(map[string]interface{})
|
||||
for nk, nv := range nodesRaw {
|
||||
if nk != "active" {
|
||||
nodesOut[nk] = nv
|
||||
}
|
||||
}
|
||||
if len(nodesOut) > 0 {
|
||||
clusterOut["nodes"] = nodesOut
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clusterOut[k] = v
|
||||
}
|
||||
}
|
||||
out["cluster"] = clusterOut
|
||||
}
|
||||
}
|
||||
|
||||
// Backup: destination + retention (not schedules — already migrated)
|
||||
if backupRaw, ok := root["backup"]; ok {
|
||||
backup, _ := backupRaw.(map[string]interface{})
|
||||
if backup != nil {
|
||||
backupOut := make(map[string]interface{})
|
||||
for k, v := range backup {
|
||||
if k != "schedules" {
|
||||
backupOut[k] = v
|
||||
}
|
||||
}
|
||||
if len(backupOut) > 0 {
|
||||
out["backup"] = backupOut
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal instance.yaml: %w", err)
|
||||
}
|
||||
return os.WriteFile(instanceYamlPath, data, 0644)
|
||||
}
|
||||
|
||||
// moveIfExists renames src to dst only if src exists and dst does not.
|
||||
func moveIfExists(src, dst string) error {
|
||||
if !storage.FileExists(src) {
|
||||
return nil
|
||||
}
|
||||
if storage.FileExists(dst) {
|
||||
return nil // already done
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
||||
// moveDir renames srcDir to dstDir if srcDir exists and dstDir does not.
|
||||
func moveDir(srcDir, dstDir string) error {
|
||||
if !storage.FileExists(srcDir) {
|
||||
return nil
|
||||
}
|
||||
if storage.FileExists(dstDir) {
|
||||
return moveDirContents(srcDir, dstDir)
|
||||
}
|
||||
return os.Rename(srcDir, dstDir)
|
||||
}
|
||||
|
||||
// moveDirContents moves each entry inside srcDir into dstDir, then removes srcDir.
|
||||
// dstDir must already exist.
|
||||
func moveDirContents(srcDir, dstDir string) error {
|
||||
if !storage.FileExists(srcDir) {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
src := filepath.Join(srcDir, entry.Name())
|
||||
dst := filepath.Join(dstDir, entry.Name())
|
||||
if !storage.FileExists(dst) {
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("move %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -98,10 +98,7 @@ type nodeCacheFile struct {
|
||||
|
||||
// List returns all nodes for an instance
|
||||
func (m *Manager) List(instanceName string) ([]Node, error) {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return m.listFromNodeFiles(instanceName)
|
||||
}
|
||||
return m.listFromConfig(instanceName)
|
||||
return m.listFromNodeFiles(instanceName)
|
||||
}
|
||||
|
||||
// listFromNodeFiles reads nodes from config/nodes/*.yaml + cache/nodes/*.yaml
|
||||
@@ -160,71 +157,6 @@ func (m *Manager) listFromNodeFiles(instanceName string) ([]Node, error) {
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// listFromConfig reads nodes from the legacy config.yaml via yq
|
||||
func (m *Manager) listFromConfig(instanceName string) ([]Node, error) {
|
||||
instancePath := m.GetInstancePath(instanceName)
|
||||
configPath := filepath.Join(instancePath, "config.yaml")
|
||||
|
||||
yq := tools.NewYQ()
|
||||
|
||||
output, err := yq.Exec("eval", ".cluster.nodes.active | keys", configPath)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "cannot get keys") || strings.Contains(err.Error(), "!!null") {
|
||||
return []Node{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read nodes: %w", err)
|
||||
}
|
||||
|
||||
hostnamesYAML := strings.TrimSpace(string(output))
|
||||
if hostnamesYAML == "" || hostnamesYAML == "null" || hostnamesYAML == "[]" {
|
||||
return []Node{}, nil
|
||||
}
|
||||
|
||||
var hostnames []string
|
||||
for _, line := range strings.Split(hostnamesYAML, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "null" && line != "-" {
|
||||
hostname := line
|
||||
if len(hostname) > 2 && hostname[0:2] == "- " {
|
||||
hostname = hostname[2:]
|
||||
}
|
||||
if hostname != "" {
|
||||
hostnames = append(hostnames, hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nodes []Node
|
||||
for _, hostname := range hostnames {
|
||||
basePath := fmt.Sprintf(".cluster.nodes.active.%s", hostname)
|
||||
|
||||
role, _ := yq.Exec("eval", basePath+".role", configPath)
|
||||
targetIP, _ := yq.Exec("eval", basePath+".targetIp", configPath)
|
||||
currentIP, _ := yq.Exec("eval", basePath+".currentIp", configPath)
|
||||
disk, _ := yq.Exec("eval", basePath+".disk", configPath)
|
||||
iface, _ := yq.Exec("eval", basePath+".interface", configPath)
|
||||
version, _ := yq.Exec("eval", basePath+".version", configPath)
|
||||
schematicID, _ := yq.Exec("eval", basePath+".schematicId", configPath)
|
||||
maintenance, _ := yq.Exec("eval", basePath+".maintenance", configPath)
|
||||
configured, _ := yq.Exec("eval", basePath+".configured", configPath)
|
||||
applied, _ := yq.Exec("eval", basePath+".applied", configPath)
|
||||
|
||||
nodes = append(nodes, Node{
|
||||
Hostname: hostname,
|
||||
Role: tools.CleanYQOutput(string(role)),
|
||||
TargetIP: tools.CleanYQOutput(string(targetIP)),
|
||||
CurrentIP: tools.CleanYQOutput(string(currentIP)),
|
||||
Disk: tools.CleanYQOutput(string(disk)),
|
||||
Interface: tools.CleanYQOutput(string(iface)),
|
||||
Version: tools.CleanYQOutput(string(version)),
|
||||
SchematicID: tools.CleanYQOutput(string(schematicID)),
|
||||
Maintenance: tools.CleanYQOutput(string(maintenance)) == "true",
|
||||
Configured: tools.CleanYQOutput(string(configured)) == "true",
|
||||
Applied: tools.CleanYQOutput(string(applied)) == "true",
|
||||
})
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// Get returns a specific node by hostname
|
||||
func (m *Manager) Get(instanceName, hostname string) (*Node, error) {
|
||||
@@ -244,12 +176,10 @@ func (m *Manager) Get(instanceName, hostname string) (*Node, error) {
|
||||
return nil, fmt.Errorf("node %s not found", hostname)
|
||||
}
|
||||
|
||||
// Add registers a new node in config.yaml
|
||||
// Add registers a new node
|
||||
func (m *Manager) Add(instanceName string, node *Node) error {
|
||||
slog.Info("adding node", "component", "node", "instance", instanceName, "hostname", node.Hostname, "role", node.Role)
|
||||
|
||||
instancePath := m.GetInstancePath(instanceName)
|
||||
|
||||
// Validate node data
|
||||
if node.Hostname == "" {
|
||||
return fmt.Errorf("hostname is required")
|
||||
@@ -272,65 +202,7 @@ func (m *Manager) Add(instanceName string, node *Node) error {
|
||||
node.Maintenance = true
|
||||
}
|
||||
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return m.addNodeFile(instanceName, node)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(instancePath, "config.yaml")
|
||||
yq := tools.NewYQ()
|
||||
|
||||
// Fill defaults from cluster.nodes.talos.*
|
||||
if node.SchematicID == "" {
|
||||
if v, err := yq.Get(configPath, ".cluster.nodes.talos.schematicId"); err == nil && v != "" && v != "null" {
|
||||
node.SchematicID = v
|
||||
}
|
||||
}
|
||||
if node.Version == "" {
|
||||
if v, err := yq.Get(configPath, ".cluster.nodes.talos.version"); err == nil && v != "" && v != "null" {
|
||||
node.Version = v
|
||||
}
|
||||
}
|
||||
|
||||
basePath := fmt.Sprintf("cluster.nodes.active.%s", node.Hostname)
|
||||
|
||||
if err := yq.Set(configPath, basePath+".role", node.Role); err != nil {
|
||||
return fmt.Errorf("failed to set role: %w", err)
|
||||
}
|
||||
if err := yq.Set(configPath, basePath+".disk", node.Disk); err != nil {
|
||||
return fmt.Errorf("failed to set disk: %w", err)
|
||||
}
|
||||
if node.TargetIP != "" {
|
||||
if err := yq.Set(configPath, basePath+".targetIp", node.TargetIP); err != nil {
|
||||
return fmt.Errorf("failed to set targetIP: %w", err)
|
||||
}
|
||||
}
|
||||
if node.CurrentIP != "" {
|
||||
if err := yq.Set(configPath, basePath+".currentIp", node.CurrentIP); err != nil {
|
||||
return fmt.Errorf("failed to set currentIP: %w", err)
|
||||
}
|
||||
}
|
||||
if node.Interface != "" {
|
||||
if err := yq.Set(configPath, basePath+".interface", node.Interface); err != nil {
|
||||
return fmt.Errorf("failed to set interface: %w", err)
|
||||
}
|
||||
}
|
||||
if node.Version != "" {
|
||||
if err := yq.Set(configPath, basePath+".version", node.Version); err != nil {
|
||||
return fmt.Errorf("failed to set version: %w", err)
|
||||
}
|
||||
}
|
||||
if node.SchematicID != "" {
|
||||
if err := yq.Set(configPath, basePath+".schematicId", node.SchematicID); err != nil {
|
||||
return fmt.Errorf("failed to set schematicId: %w", err)
|
||||
}
|
||||
}
|
||||
if node.Maintenance {
|
||||
if err := yq.Set(configPath, basePath+".maintenance", "true"); err != nil {
|
||||
return fmt.Errorf("failed to set maintenance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return m.addNodeFile(instanceName, node)
|
||||
}
|
||||
|
||||
// addNodeFile writes a new node's config to config/nodes/{hostname}.yaml (new format)
|
||||
@@ -426,28 +298,15 @@ func (m *Manager) Delete(instanceName, nodeIdentifier string, skipReset bool) er
|
||||
return m.deleteFromConfig(instanceName, node.Hostname)
|
||||
}
|
||||
|
||||
// deleteFromConfig removes a node entry from config (format-aware)
|
||||
// deleteFromConfig removes a node entry from config
|
||||
func (m *Manager) deleteFromConfig(instanceName, hostname string) error {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
configPath := tools.GetNodeConfigPath(m.dataDir, instanceName, hostname)
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete node config: %w", err)
|
||||
}
|
||||
cachePath := tools.GetNodeCachePath(m.dataDir, instanceName, hostname)
|
||||
if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("failed to delete node cache", "component", "node", "hostname", hostname, "error", err)
|
||||
}
|
||||
return nil
|
||||
configPath := tools.GetNodeConfigPath(m.dataDir, instanceName, hostname)
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete node config: %w", err)
|
||||
}
|
||||
|
||||
instancePath := m.GetInstancePath(instanceName)
|
||||
configPath := filepath.Join(instancePath, "config.yaml")
|
||||
nodePath := fmt.Sprintf(".cluster.nodes.active[\"%s\"]", hostname)
|
||||
yq := tools.NewYQ()
|
||||
delExpr := fmt.Sprintf("del(%s)", nodePath)
|
||||
_, err := yq.Exec("eval", "-i", delExpr, configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete node: %w", err)
|
||||
cachePath := tools.GetNodeCachePath(m.dataDir, instanceName, hostname)
|
||||
if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("failed to delete node cache", "component", "node", "hostname", hostname, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -738,41 +597,9 @@ func (m *Manager) extractEmbeddedTemplates(destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateNodeStatus updates node status flags (format-aware)
|
||||
// updateNodeStatus updates node status flags
|
||||
func (m *Manager) updateNodeStatus(instanceName string, node *Node) error {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return m.updateNodeStatusFiles(instanceName, node)
|
||||
}
|
||||
|
||||
instancePath := m.GetInstancePath(instanceName)
|
||||
configPath := filepath.Join(instancePath, "config.yaml")
|
||||
basePath := fmt.Sprintf("cluster.nodes.active.%s", node.Hostname)
|
||||
yq := tools.NewYQ()
|
||||
|
||||
maintenanceVal := "false"
|
||||
if node.Maintenance {
|
||||
maintenanceVal = "true"
|
||||
}
|
||||
if err := yq.Set(configPath, basePath+".maintenance", maintenanceVal); err != nil {
|
||||
return err
|
||||
}
|
||||
if node.CurrentIP != "" {
|
||||
if err := yq.Set(configPath, basePath+".currentIp", node.CurrentIP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
configuredValue := "false"
|
||||
if node.Configured {
|
||||
configuredValue = "true"
|
||||
}
|
||||
if err := yq.Set(configPath, basePath+".configured", configuredValue); err != nil {
|
||||
return err
|
||||
}
|
||||
appliedValue := "false"
|
||||
if node.Applied {
|
||||
appliedValue = "true"
|
||||
}
|
||||
return yq.Set(configPath, basePath+".applied", appliedValue)
|
||||
return m.updateNodeStatusFiles(instanceName, node)
|
||||
}
|
||||
|
||||
// updateNodeStatusFiles writes cache (applied/configured/currentIp) and updates maintenance in config (new format)
|
||||
@@ -814,74 +641,7 @@ func (m *Manager) updateNodeStatusFiles(instanceName string, node *Node) error {
|
||||
|
||||
// Update modifies existing node configuration with partial updates
|
||||
func (m *Manager) Update(instanceName string, hostname string, updates map[string]interface{}) error {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
return m.updateNodeFile(instanceName, hostname, updates)
|
||||
}
|
||||
|
||||
node, err := m.Get(instanceName, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("node %s not found", hostname)
|
||||
}
|
||||
|
||||
instancePath := m.GetInstancePath(instanceName)
|
||||
configPath := filepath.Join(instancePath, "config.yaml")
|
||||
basePath := fmt.Sprintf("cluster.nodes.active.%s", hostname)
|
||||
yq := tools.NewYQ()
|
||||
|
||||
for key, value := range updates {
|
||||
switch key {
|
||||
case "target_ip":
|
||||
if strVal, ok := value.(string); ok {
|
||||
node.TargetIP = strVal
|
||||
if err := yq.Set(configPath, basePath+".targetIp", strVal); err != nil {
|
||||
return fmt.Errorf("failed to update targetIp: %w", err)
|
||||
}
|
||||
}
|
||||
case "current_ip":
|
||||
if strVal, ok := value.(string); ok {
|
||||
node.CurrentIP = strVal
|
||||
node.Maintenance = true
|
||||
if err := yq.Set(configPath, basePath+".currentIp", strVal); err != nil {
|
||||
return fmt.Errorf("failed to update currentIp: %w", err)
|
||||
}
|
||||
if err := yq.Set(configPath, basePath+".maintenance", "true"); err != nil {
|
||||
return fmt.Errorf("failed to set maintenance: %w", err)
|
||||
}
|
||||
}
|
||||
case "disk":
|
||||
if strVal, ok := value.(string); ok {
|
||||
if err := yq.Set(configPath, basePath+".disk", strVal); err != nil {
|
||||
return fmt.Errorf("failed to update disk: %w", err)
|
||||
}
|
||||
}
|
||||
case "interface":
|
||||
if strVal, ok := value.(string); ok {
|
||||
if err := yq.Set(configPath, basePath+".interface", strVal); err != nil {
|
||||
return fmt.Errorf("failed to update interface: %w", err)
|
||||
}
|
||||
}
|
||||
case "version":
|
||||
if strVal, ok := value.(string); ok {
|
||||
if err := yq.Set(configPath, basePath+".version", strVal); err != nil {
|
||||
return fmt.Errorf("failed to update version: %w", err)
|
||||
}
|
||||
}
|
||||
case "schematic_id":
|
||||
if strVal, ok := value.(string); ok {
|
||||
if err := yq.Set(configPath, basePath+".schematicId", strVal); err != nil {
|
||||
return fmt.Errorf("failed to update schematicId: %w", err)
|
||||
}
|
||||
}
|
||||
case "maintenance":
|
||||
if boolVal, ok := value.(bool); ok {
|
||||
if err := yq.Set(configPath, basePath+".maintenance", fmt.Sprintf("%t", boolVal)); err != nil {
|
||||
return fmt.Errorf("failed to update maintenance: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return m.updateNodeFile(instanceName, hostname, updates)
|
||||
}
|
||||
|
||||
// updateNodeFile applies partial updates to config/nodes/{hostname}.yaml (new format)
|
||||
@@ -940,44 +700,27 @@ func (m *Manager) FetchTemplates(instanceName string) error {
|
||||
return m.extractEmbeddedTemplates(destDir)
|
||||
}
|
||||
|
||||
// fillTalosDefaults reads cluster talos version/schematicId defaults into node (format-aware)
|
||||
// fillTalosDefaults reads cluster talos version/schematicId defaults into node from instance.yaml
|
||||
func (m *Manager) fillTalosDefaults(instanceName string, node *Node) {
|
||||
if tools.IsNewInstanceFormat(m.dataDir, instanceName) {
|
||||
instanceYaml := tools.GetInstanceYamlPath(m.dataDir, instanceName)
|
||||
if data, err := os.ReadFile(instanceYaml); err == nil {
|
||||
var root struct {
|
||||
Cluster struct {
|
||||
Nodes struct {
|
||||
Talos struct {
|
||||
SchematicID string `yaml:"schematicId"`
|
||||
Version string `yaml:"version"`
|
||||
} `yaml:"talos"`
|
||||
} `yaml:"nodes"`
|
||||
} `yaml:"cluster"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &root); err == nil {
|
||||
if node.SchematicID == "" {
|
||||
node.SchematicID = root.Cluster.Nodes.Talos.SchematicID
|
||||
}
|
||||
if node.Version == "" {
|
||||
node.Version = root.Cluster.Nodes.Talos.Version
|
||||
}
|
||||
}
|
||||
instanceYaml := tools.GetInstanceYamlPath(m.dataDir, instanceName)
|
||||
if data, err := os.ReadFile(instanceYaml); err == nil {
|
||||
var root struct {
|
||||
Cluster struct {
|
||||
Nodes struct {
|
||||
Talos struct {
|
||||
SchematicID string `yaml:"schematicId"`
|
||||
Version string `yaml:"version"`
|
||||
} `yaml:"talos"`
|
||||
} `yaml:"nodes"`
|
||||
} `yaml:"cluster"`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
instancePath := m.GetInstancePath(instanceName)
|
||||
configPath := filepath.Join(instancePath, "config.yaml")
|
||||
yq := tools.NewYQ()
|
||||
if node.Version == "" {
|
||||
if v, err := yq.Get(configPath, ".cluster.nodes.talos.version"); err == nil && v != "" && v != "null" {
|
||||
node.Version = v
|
||||
}
|
||||
}
|
||||
if node.SchematicID == "" {
|
||||
if s, err := yq.Get(configPath, ".cluster.nodes.talos.schematicId"); err == nil && s != "" && s != "null" {
|
||||
node.SchematicID = s
|
||||
if err := yaml.Unmarshal(data, &root); err == nil {
|
||||
if node.SchematicID == "" {
|
||||
node.SchematicID = root.Cluster.Nodes.Talos.SchematicID
|
||||
}
|
||||
if node.Version == "" {
|
||||
node.Version = root.Cluster.Nodes.Talos.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,22 @@ func DetectSetupStatus(instanceName, dataDir string) (*SetupStatus, error) {
|
||||
|
||||
// 3. Control nodes phase - at least 3 control plane nodes configured
|
||||
controlNodeCount := 0
|
||||
for _, node := range instanceConfig.Cluster.Nodes.Active {
|
||||
if node.Role == "controlplane" {
|
||||
controlNodeCount++
|
||||
nodeConfigDir := tools.GetNodeConfigDirPath(dataDir, instanceName)
|
||||
if entries, err := os.ReadDir(nodeConfigDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(nodeConfigDir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var nodeConfig struct {
|
||||
Role string `yaml:"role"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &nodeConfig); err == nil && nodeConfig.Role == "controlplane" {
|
||||
controlNodeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
controlNodesComplete := controlNodeCount >= 3 &&
|
||||
@@ -249,7 +262,7 @@ func checkServicesInstalled(dataDir, instanceName, kubeconfigPath string) bool {
|
||||
|
||||
// Count infrastructure packages that have been added to the instance.
|
||||
// A package is infrastructure if its manifest has category: infrastructure.
|
||||
appsDir := filepath.Join(dataDir, "instances", instanceName, "apps")
|
||||
appsDir := filepath.Join(tools.GetDataDirPath(dataDir, instanceName), "apps")
|
||||
entries, err := os.ReadDir(appsDir)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -282,7 +295,7 @@ func checkServicesInstalled(dataDir, instanceName, kubeconfigPath string) bool {
|
||||
|
||||
func checkAppsDeployed(dataDir, instanceName string) bool {
|
||||
// Check if apps directory exists and has at least one app
|
||||
appsDir := filepath.Join(dataDir, "instances", instanceName, "apps")
|
||||
appsDir := filepath.Join(tools.GetDataDirPath(dataDir, instanceName), "apps")
|
||||
if !fileExists(appsDir) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -26,13 +26,6 @@ func WithKubeconfig(cmd *exec.Cmd, kubeconfigPath string) *exec.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// IsNewInstanceFormat returns true if the instance has been migrated to the XDG layout.
|
||||
// Detection: presence of config/instance.yaml at the instance root.
|
||||
func IsNewInstanceFormat(dataDir, instanceName string) bool {
|
||||
_, err := os.Stat(GetInstanceYamlPath(dataDir, instanceName))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// config/ tree — operator-authored intent (the only files a human should edit)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -47,21 +40,14 @@ func GetInstanceYamlPath(dataDir, instanceName string) string {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml")
|
||||
}
|
||||
|
||||
// GetInstanceConfigPath returns the path to an instance's config file.
|
||||
// In the legacy format this is config.yaml at the instance root; new format
|
||||
// callers that need the monolithic config should still read config.yaml for
|
||||
// sections not yet migrated.
|
||||
// GetInstanceConfigPath returns the path to an instance's primary config file.
|
||||
func GetInstanceConfigPath(dataDir, instanceName string) string {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "config.yaml")
|
||||
return GetInstanceYamlPath(dataDir, instanceName)
|
||||
}
|
||||
|
||||
// GetInstanceSecretsPath returns the path to an instance's secrets file.
|
||||
// Returns config/secrets.yaml in the new format and secrets.yaml in legacy.
|
||||
func GetInstanceSecretsPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "secrets.yaml")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml")
|
||||
}
|
||||
|
||||
// GetNodeConfigDirPath returns the path to the per-node config directory (new format)
|
||||
@@ -104,12 +90,8 @@ func GetDataDirPath(dataDir, instanceName string) string {
|
||||
}
|
||||
|
||||
// GetKubeconfigPath returns the path to the kubeconfig for an instance.
|
||||
// Returns data/kubeconfig in the new format and kubeconfig at root in legacy.
|
||||
func GetKubeconfigPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "kubeconfig")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "kubeconfig")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "kubeconfig")
|
||||
}
|
||||
|
||||
// GetAppPackagePath returns the path to a compiled app package directory (new format)
|
||||
@@ -123,39 +105,23 @@ func GetTalosDirPath(dataDir, instanceName string) string {
|
||||
}
|
||||
|
||||
// GetTalosPkiPath returns the path to the Talos PKI secrets file.
|
||||
// Returns data/talos/pki.yaml in new format and talos/generated/secrets.yaml in legacy.
|
||||
func GetTalosPkiPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "talos", "pki.yaml")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "talos", "generated", "secrets.yaml")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "talos", "pki.yaml")
|
||||
}
|
||||
|
||||
// GetTalosconfigPath returns the path to the talosconfig for an instance.
|
||||
// Returns data/talos/talosconfig in new format and talos/generated/talosconfig in legacy.
|
||||
func GetTalosconfigPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "talos", "talosconfig")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "talos", "generated", "talosconfig")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "talos", "talosconfig")
|
||||
}
|
||||
|
||||
// GetControlplaneConfigPath returns the path to the generated controlplane machine config.
|
||||
// Returns data/talos/controlplane.yaml in new format and talos/generated/controlplane.yaml in legacy.
|
||||
func GetControlplaneConfigPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "talos", "controlplane.yaml")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "talos", "generated", "controlplane.yaml")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "talos", "controlplane.yaml")
|
||||
}
|
||||
|
||||
// GetWorkerConfigPath returns the path to the generated worker machine config.
|
||||
// Returns data/talos/worker.yaml in new format and talos/generated/worker.yaml in legacy.
|
||||
func GetWorkerConfigPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "talos", "worker.yaml")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "talos", "generated", "worker.yaml")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "talos", "worker.yaml")
|
||||
}
|
||||
|
||||
// GetBackupRecordsPath returns the path to the backup records directory (new format)
|
||||
@@ -164,12 +130,8 @@ func GetBackupRecordsPath(dataDir, instanceName string) string {
|
||||
}
|
||||
|
||||
// GetInstanceBackupsPath returns the path to an instance's backup records.
|
||||
// Returns data/backup/records in new format and backups in legacy.
|
||||
func GetInstanceBackupsPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "backup", "records")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "backups")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "data", "backup", "records")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -177,12 +139,8 @@ func GetInstanceBackupsPath(dataDir, instanceName string) string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetOperationsPath returns the path to the operations log directory.
|
||||
// Returns state/operations in new format and operations in legacy.
|
||||
func GetOperationsPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "state", "operations")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "operations")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "state", "operations")
|
||||
}
|
||||
|
||||
// GetInstanceOperationsPath is an alias for GetOperationsPath.
|
||||
@@ -215,30 +173,18 @@ func GetNodeCachePath(dataDir, instanceName, hostname string) string {
|
||||
}
|
||||
|
||||
// GetNodeSetupCachePath returns the path to the node setup cache directory.
|
||||
// Returns cache/nodes/setup in new format and setup/cluster-nodes in legacy.
|
||||
func GetNodeSetupCachePath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "cache", "nodes", "setup")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "setup", "cluster-nodes")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "cache", "nodes", "setup")
|
||||
}
|
||||
|
||||
// GetDiscoveryCachePath returns the path to the discovery cache file.
|
||||
// Returns cache/discovery.json in new format and discovery/status.json in legacy.
|
||||
func GetDiscoveryCachePath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "cache", "discovery.json")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "discovery", "status.json")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "cache", "discovery.json")
|
||||
}
|
||||
|
||||
// GetInstanceDiscoveryPath returns the discovery directory path.
|
||||
// Returns cache/ dir in new format and discovery/ in legacy.
|
||||
func GetInstanceDiscoveryPath(dataDir, instanceName string) string {
|
||||
if IsNewInstanceFormat(dataDir, instanceName) {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "cache")
|
||||
}
|
||||
return filepath.Join(dataDir, "instances", instanceName, "discovery")
|
||||
return filepath.Join(dataDir, "instances", instanceName, "cache")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -229,6 +229,29 @@ var clusterTalosconfigCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var clusterTalosconfigRenewCmd = &cobra.Command{
|
||||
Use: "renew",
|
||||
Short: "Renew talosconfig client certificate",
|
||||
Long: `Regenerate the talosconfig client certificate from the existing PKI root.
|
||||
|
||||
Use this when the talosconfig certificate has expired (certificates are valid for 1 year).
|
||||
The cluster PKI root (pki.yaml) must exist — this does not require live cluster access.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inst, err := getInstanceName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/cluster/talosconfig/renew", inst), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Talosconfig certificate renewed successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var clusterEndpointsCmd = &cobra.Command{
|
||||
Use: "endpoints",
|
||||
Short: "Configure cluster endpoints to use VIP",
|
||||
@@ -271,6 +294,7 @@ func init() {
|
||||
clusterCmd.AddCommand(clusterKubeconfigCmd)
|
||||
clusterCmd.AddCommand(clusterConfigGenerateCmd)
|
||||
clusterCmd.AddCommand(clusterTalosconfigCmd)
|
||||
clusterTalosconfigCmd.AddCommand(clusterTalosconfigRenewCmd)
|
||||
clusterCmd.AddCommand(clusterEndpointsCmd)
|
||||
|
||||
clusterEndpointsCmd.Flags().Bool("nodes", false, "Include all control node IPs as fallback endpoints")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useConfigYaml, useCentralStatus } from '../hooks';
|
||||
import { useConfigFile, useConfigFileList, useCentralStatus } from '../hooks';
|
||||
import { Button, Textarea } from './ui';
|
||||
import {
|
||||
Card,
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "./ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
import { Save, RotateCcw, FileText } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
@@ -17,48 +24,62 @@ interface ConfigEditorProps {
|
||||
|
||||
export function ConfigEditor({ className }: ConfigEditorProps) {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const { yamlContent, isLoading, error, isEndpointMissing, updateYaml } = useConfigYaml(instanceId || '');
|
||||
const { data: centralStatus } = useCentralStatus();
|
||||
const { data: fileListData, isLoading: isLoadingFiles } = useConfigFileList(instanceId || '');
|
||||
|
||||
// Construct the file path from the data directory and instance name
|
||||
const configFilePath = centralStatus?.dataDir && instanceId
|
||||
? `${centralStatus.dataDir}/instances/${instanceId}/config.yaml`
|
||||
: null;
|
||||
const files = fileListData?.files ?? [];
|
||||
const defaultFile = files[0]?.path ?? 'config/instance.yaml';
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<string>('');
|
||||
const [editedContent, setEditedContent] = useState('');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Update edited content when YAML content changes
|
||||
// Set default selection once the file list loads
|
||||
useEffect(() => {
|
||||
if (yamlContent) {
|
||||
if (files.length > 0 && !selectedFile) {
|
||||
setSelectedFile(files[0].path);
|
||||
}
|
||||
}, [files, selectedFile]);
|
||||
|
||||
const activePath = selectedFile || defaultFile;
|
||||
const { yamlContent, isLoading, error, updateYaml, isUpdating } = useConfigFile(instanceId || '', activePath);
|
||||
|
||||
// Sync editor content when file or its content changes
|
||||
useEffect(() => {
|
||||
if (yamlContent !== undefined) {
|
||||
setEditedContent(yamlContent);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [yamlContent]);
|
||||
}, [yamlContent, activePath]);
|
||||
|
||||
// Track changes
|
||||
// Track unsaved changes
|
||||
useEffect(() => {
|
||||
setHasChanges(editedContent !== yamlContent);
|
||||
}, [editedContent, yamlContent]);
|
||||
|
||||
const configFilePath = centralStatus?.dataDir && instanceId && activePath
|
||||
? `${centralStatus.dataDir}/instances/${instanceId}/${activePath}`
|
||||
: null;
|
||||
|
||||
const handleFileChange = (newPath: string) => {
|
||||
if (hasChanges) {
|
||||
if (!window.confirm('You have unsaved changes. Switch files and discard them?')) return;
|
||||
}
|
||||
setSelectedFile(newPath);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasChanges) return;
|
||||
|
||||
updateYaml(editedContent, {
|
||||
onSuccess: () => {
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Failed to update config:', err);
|
||||
}
|
||||
onSuccess: () => setHasChanges(false),
|
||||
onError: (err) => console.error('Failed to update config:', err),
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (yamlContent) {
|
||||
setEditedContent(yamlContent);
|
||||
setHasChanges(false);
|
||||
}
|
||||
setEditedContent(yamlContent);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -68,7 +89,7 @@ export function ConfigEditor({ className }: ConfigEditorProps) {
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Configuration Editor</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Edit the raw YAML configuration file
|
||||
Edit raw YAML configuration files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,71 +99,84 @@ export function ConfigEditor({ className }: ConfigEditorProps) {
|
||||
<CardDescription>
|
||||
This provides direct access to all configuration options.
|
||||
<span className="block mt-2 text-orange-600 dark:text-orange-400">
|
||||
Warning: Incorrect changes can break this instance. Most users don't need to edit this manually. Ensure you have a backup before making changes.
|
||||
Warning: Incorrect changes can break this instance. Ensure you have a backup before making changes.
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col flex-1 min-h-0">
|
||||
{error && error instanceof Error && error.message && (
|
||||
<div className="p-3 mb-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md shrink-0">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
Error: {error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex flex-col flex-1 min-h-0">
|
||||
{error && (
|
||||
<div className="p-3 mb-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md shrink-0">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
Error: {error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEndpointMissing && (
|
||||
<div className="p-3 mb-4 bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-md shrink-0">
|
||||
<p className="text-sm text-orange-800 dark:text-orange-200">
|
||||
Backend endpoints missing. Raw YAML editing not available.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configFilePath && (
|
||||
<div className="flex items-center gap-2 mb-3 p-2 bg-muted rounded-md shrink-0">
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<code className="text-sm font-mono text-muted-foreground truncate">
|
||||
{configFilePath}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={editedContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditedContent(e.target.value)}
|
||||
placeholder={isLoading ? "Loading YAML configuration..." : "No configuration found"}
|
||||
disabled={isLoading || !!isEndpointMissing}
|
||||
className="font-mono text-sm w-full flex-1 min-h-0 resize-none whitespace-pre overflow-x-auto"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 shrink-0">
|
||||
<div>
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-orange-600 dark:text-orange-400">
|
||||
You have unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isLoading}
|
||||
{/* File selector */}
|
||||
<div className="mb-3 shrink-0">
|
||||
<Select
|
||||
value={activePath}
|
||||
onValueChange={handleFileChange}
|
||||
disabled={isLoadingFiles || files.length === 0}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isLoading || !!isEndpointMissing}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a config file…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{files.map((f) => (
|
||||
<SelectItem key={f.path} value={f.path}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* File path display */}
|
||||
{configFilePath && (
|
||||
<div className="flex items-center gap-2 mb-3 p-2 bg-muted rounded-md shrink-0">
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<code className="text-sm font-mono text-muted-foreground truncate">
|
||||
{configFilePath}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={editedContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditedContent(e.target.value)}
|
||||
placeholder={isLoading ? "Loading configuration…" : "No configuration found"}
|
||||
disabled={isLoading || isUpdating}
|
||||
className="font-mono text-sm w-full flex-1 min-h-0 resize-none whitespace-pre overflow-x-auto"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 shrink-0">
|
||||
<div>
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-orange-600 dark:text-orange-400">
|
||||
You have unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isLoading}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isLoading || isUpdating}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ export { useMessages } from './useMessages';
|
||||
export { useStatus } from './useStatus';
|
||||
export { useHealth } from './useHealth';
|
||||
export { useConfig } from './useConfig';
|
||||
export { useConfigYaml } from './useConfigYaml';
|
||||
export { useConfigYaml, useConfigFile, useConfigFileList } from './useConfigYaml';
|
||||
export { useDnsmasq } from './useDnsmasq';
|
||||
export { useAssets } from './useAssets';
|
||||
|
||||
|
||||
@@ -38,4 +38,39 @@ export const useConfigYaml = (instanceId: string) => {
|
||||
updateYaml: updateConfigYamlMutation.mutate,
|
||||
refetch: configYamlQuery.refetch,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const useConfigFile = (instanceId: string, filePath: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const fileQuery = useQuery({
|
||||
queryKey: ['instance', instanceId, 'config', 'file', filePath],
|
||||
queryFn: () => instancesApi.getConfigFile(instanceId, filePath),
|
||||
staleTime: 30000,
|
||||
enabled: !!instanceId && !!filePath,
|
||||
});
|
||||
|
||||
const updateFileMutation = useMutation({
|
||||
mutationFn: (content: string) => instancesApi.updateConfigFile(instanceId, filePath, content),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instance', instanceId, 'config', 'file', filePath] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
yamlContent: fileQuery.data || '',
|
||||
isLoading: fileQuery.isLoading,
|
||||
error: fileQuery.error instanceof Error ? fileQuery.error : null,
|
||||
isUpdating: updateFileMutation.isPending,
|
||||
updateYaml: updateFileMutation.mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const useConfigFileList = (instanceId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['instance', instanceId, 'config', 'files'],
|
||||
queryFn: () => instancesApi.listConfigFiles(instanceId),
|
||||
staleTime: 60000,
|
||||
enabled: !!instanceId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
} from './types';
|
||||
import type { InstanceConfig } from '../../types';
|
||||
|
||||
export interface ConfigFileEntry {
|
||||
path: string; // relative to instance root, e.g. "config/instance.yaml"
|
||||
label: string; // human-readable, e.g. "Instance Config"
|
||||
}
|
||||
|
||||
export const instancesApi = {
|
||||
async list(): Promise<InstanceListResponse> {
|
||||
return apiClient.get('/api/v1/instances');
|
||||
@@ -61,6 +66,29 @@ export const instancesApi = {
|
||||
return { message: result.message || 'Config updated successfully' };
|
||||
},
|
||||
|
||||
// Multi-file config management (XDG layout)
|
||||
async listConfigFiles(instanceName: string): Promise<{ files: ConfigFileEntry[] }> {
|
||||
return apiClient.get(`/api/v1/instances/${instanceName}/config/files`);
|
||||
},
|
||||
|
||||
async getConfigFile(instanceName: string, relPath: string): Promise<string> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/v1/instances/${instanceName}/config/file?path=${encodeURIComponent(relPath)}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
},
|
||||
|
||||
async updateConfigFile(instanceName: string, relPath: string, yamlContent: string): Promise<{ message: string }> {
|
||||
const result = await apiClient.putText(
|
||||
`/api/v1/instances/${instanceName}/config/file?path=${encodeURIComponent(relPath)}`,
|
||||
yamlContent
|
||||
);
|
||||
return { message: result.message || 'Config file updated successfully' };
|
||||
},
|
||||
|
||||
// Secrets management
|
||||
async getSecrets(instanceName: string, raw = false): Promise<Record<string, unknown>> {
|
||||
const query = raw ? '?raw=true' : '';
|
||||
|
||||
Reference in New Issue
Block a user