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:
2026-06-21 09:12:32 +00:00
parent 1d75970dd5
commit dd0166b5d5
35 changed files with 1033 additions and 2000 deletions

View File

@@ -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

View File

@@ -1 +1 @@
0.1.2
0.2.0

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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})
}

View File

@@ -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{}{

View File

@@ -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")

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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, "--",

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}
}

View File

@@ -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
}

View File

@@ -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")
}
// ---------------------------------------------------------------------------

View File

@@ -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")

View File

@@ -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>
);

View File

@@ -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';

View File

@@ -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,
});
};

View File

@@ -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' : '';