feat(api): enhance app secrets management and deployment process

- Add tests for retrieving and redacting per-app secrets in GetSecrets handler.
- Implement logic to restart workloads when ConfigMaps are updated during app deployment.
- Ensure complete cleanup of app configuration directories upon deletion, including all related files.
- Introduce a filter to skip non-Longhorn PVCs during backup operations.
- Refactor legacy helper functions in context.go to streamline instance path management.
- Update AppDetailPanel component to include action buttons for app lifecycle management and backup operations.
This commit is contained in:
2026-07-02 20:55:16 +00:00
parent 81a000930d
commit 4935ae04c4
11 changed files with 604 additions and 336 deletions

View File

@@ -449,9 +449,7 @@ func (api *API) GetSecrets(w http.ResponseWriter, r *http.Request) {
return
}
secretsPath := inst.SecretsPath
secretsData, err := os.ReadFile(secretsPath)
secretsData, err := os.ReadFile(inst.SecretsPath)
if err != nil {
if os.IsNotExist(err) {
respondJSON(w, http.StatusOK, map[string]any{})
@@ -466,13 +464,54 @@ func (api *API) GetSecrets(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse secrets: %v", err))
return
}
if secretsMap == nil {
secretsMap = map[string]any{}
}
// Load per-app secrets and include them under the "apps" key
appsConfigDir := tools.GetAppsConfigDirPath(api.dataDir, name)
if appEntries, err := os.ReadDir(appsConfigDir); err == nil {
appSecrets := map[string]any{}
for _, entry := range appEntries {
if !entry.IsDir() {
continue
}
appName := entry.Name()
data, err := os.ReadFile(tools.GetAppSecretsPath(api.dataDir, name, appName))
if err != nil {
continue
}
var appSecretsMap map[string]any
if err := yaml.Unmarshal(data, &appSecretsMap); err != nil || len(appSecretsMap) == 0 {
continue
}
appSecrets[appName] = appSecretsMap
}
if len(appSecrets) > 0 {
secretsMap["apps"] = appSecrets
}
}
// Check if client wants raw secrets (dangerous!)
showRaw := r.URL.Query().Get("raw") == "true"
if !showRaw {
// Redact secrets
for key := range secretsMap {
// Redact top-level secrets
for key, val := range secretsMap {
if key == "apps" {
// Redact per-app secrets
if appsMap, ok := val.(map[string]any); ok {
for appName, appData := range appsMap {
if appDataMap, ok := appData.(map[string]any); ok {
for k := range appDataMap {
appDataMap[k] = "********"
}
appsMap[appName] = appDataMap
}
}
}
continue
}
secretsMap[key] = "********"
}
}

View File

@@ -234,34 +234,25 @@ func (api *API) AppsGetConfig(w http.ResponseWriter, r *http.Request) {
instanceName := GetInstanceName(r)
appName := GetAppName(r)
inst, err := api.instance.GetInstance(instanceName)
if err != nil {
if err := api.instance.ValidateInstance(instanceName); err != nil {
respondError(w, http.StatusNotFound, "Instance not found")
return
}
configData, err := os.ReadFile(inst.ConfigPath)
configPath := tools.GetAppConfigPath(api.dataDir, instanceName, appName)
configData, err := os.ReadFile(configPath)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read instance config: %v", err))
return
}
var instanceConfig map[string]any
if err := yaml.Unmarshal(configData, &instanceConfig); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse instance config: %v", err))
return
}
// Extract app config
var appConfig map[string]any
if appsSection, ok := instanceConfig["apps"].(map[string]any); ok {
if appConfigData, ok := appsSection[appName].(map[string]any); ok {
appConfig = appConfigData
if os.IsNotExist(err) {
respondError(w, http.StatusNotFound, fmt.Sprintf("Config not found for app: %s", appName))
return
}
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read app config: %v", err))
return
}
if appConfig == nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Config not found for app: %s", appName))
var appConfig map[string]any
if err := yaml.Unmarshal(configData, &appConfig); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse app config: %v", err))
return
}

View File

@@ -2,6 +2,7 @@ package v1
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
@@ -12,6 +13,7 @@ import (
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/daemon/internal/storage"
"github.com/wild-cloud/wild-central/daemon/internal/tools"
)
func setupTestAPI(t *testing.T) (*API, string) {
@@ -654,3 +656,96 @@ func TestUpdateYAMLFile_PreservesComplexTypes(t *testing.T) {
t.Errorf("Null value not preserved: %v", result["nullValue"])
}
}
func TestGetSecrets_IncludesPerAppSecrets(t *testing.T) {
api, tmpDir := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
// Write a per-app secrets file
appSecretsPath := tools.GetAppSecretsPath(tmpDir, instanceName, "vllm")
if err := os.MkdirAll(filepath.Dir(appSecretsPath), 0755); err != nil {
t.Fatalf("Failed to create app secrets dir: %v", err)
}
if err := os.WriteFile(appSecretsPath, []byte("apiKey: mykey123\n"), 0600); err != nil {
t.Fatalf("Failed to write app secrets: %v", err)
}
req := httptest.NewRequest("GET", "/api/v1/instances/"+instanceName+"/secrets?raw=true", nil)
w := httptest.NewRecorder()
req = mux.SetURLVars(req, map[string]string{"name": instanceName})
api.GetSecrets(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
appsRaw, ok := result["apps"]
if !ok {
t.Fatal("Expected 'apps' key in response")
}
appsMap, ok := appsRaw.(map[string]any)
if !ok {
t.Fatalf("Expected 'apps' to be a map, got %T", appsRaw)
}
vllmRaw, ok := appsMap["vllm"]
if !ok {
t.Fatal("Expected 'vllm' in apps secrets")
}
vllmMap, ok := vllmRaw.(map[string]any)
if !ok {
t.Fatalf("Expected vllm secrets to be a map, got %T", vllmRaw)
}
if vllmMap["apiKey"] != "mykey123" {
t.Errorf("Expected apiKey='mykey123', got %v", vllmMap["apiKey"])
}
}
func TestGetSecrets_RedactsPerAppSecrets(t *testing.T) {
api, tmpDir := setupTestAPI(t)
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
appSecretsPath := tools.GetAppSecretsPath(tmpDir, instanceName, "vllm")
if err := os.MkdirAll(filepath.Dir(appSecretsPath), 0755); err != nil {
t.Fatalf("Failed to create app secrets dir: %v", err)
}
if err := os.WriteFile(appSecretsPath, []byte("apiKey: mykey123\n"), 0600); err != nil {
t.Fatalf("Failed to write app secrets: %v", err)
}
// Request without ?raw=true — should be redacted
req := httptest.NewRequest("GET", "/api/v1/instances/"+instanceName+"/secrets", nil)
w := httptest.NewRecorder()
req = mux.SetURLVars(req, map[string]string{"name": instanceName})
api.GetSecrets(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
appsRaw, ok := result["apps"]
if !ok {
t.Fatal("Expected 'apps' key in response")
}
appsMap := appsRaw.(map[string]any)
vllmMap := appsMap["vllm"].(map[string]any)
if vllmMap["apiKey"] == "mykey123" {
t.Error("apiKey should be redacted but got raw value")
}
if vllmMap["apiKey"] != "********" {
t.Errorf("Expected redacted value '********', got %v", vllmMap["apiKey"])
}
}

View File

@@ -1015,6 +1015,12 @@ func (m *Manager) Deploy(inst *instance.Instance, appName string, opID string, b
if err != nil {
return fmt.Errorf("failed to deploy phase %s: %w\nOutput: %s", phase.Path, err, string(output))
}
if hasConfigMapChanges(output) {
slog.Info("ConfigMap updated, restarting workloads", "component", "apps", "namespace", namespace)
if err := m.restartNamespaceWorkloads(kubeconfigPath, namespace); err != nil {
return err
}
}
if phase.WaitFor != nil {
if err := m.waitForRollout(kubeconfigPath, namespace, phase.WaitFor); err != nil {
@@ -1029,6 +1035,12 @@ func (m *Manager) Deploy(inst *instance.Instance, appName string, opID string, b
if err != nil {
return fmt.Errorf("failed to deploy app: %w\nOutput: %s", err, string(output))
}
if hasConfigMapChanges(output) {
slog.Info("ConfigMap updated, restarting workloads", "component", "apps", "namespace", namespace)
if err := m.restartNamespaceWorkloads(kubeconfigPath, namespace); err != nil {
return err
}
}
}
// Post-deploy: restart deployments
@@ -1074,25 +1086,24 @@ func (m *Manager) waitForRollout(kubeconfigPath, namespace string, wait *Rollout
// Restart performs a rolling restart of all deployments and statefulsets in an app's namespace
func (m *Manager) Restart(inst *instance.Instance, appName string) error {
slog.Info("restarting app", "component", "apps", "instance", inst.Name, "app", appName)
kubeconfigPath := inst.KubeconfigPath()
namespace := m.ResolveNamespace(inst, appName)
return m.restartNamespaceWorkloads(kubeconfigPath, namespace)
}
// Restart deployments
// restartNamespaceWorkloads restarts all deployments and statefulsets in the namespace.
func (m *Manager) restartNamespaceWorkloads(kubeconfigPath, namespace string) error {
deployCmd := exec.Command("kubectl", "rollout", "restart", "deployment", "-n", namespace)
tools.WithKubeconfig(deployCmd, kubeconfigPath)
if output, err := deployCmd.CombinedOutput(); err != nil {
// Ignore "no resources found" errors - namespace may only have statefulsets
if !strings.Contains(string(output), "no resources found") {
return fmt.Errorf("failed to restart deployments: %w\nOutput: %s", err, string(output))
}
}
// Restart statefulsets
stsCmd := exec.Command("kubectl", "rollout", "restart", "statefulset", "-n", namespace)
tools.WithKubeconfig(stsCmd, kubeconfigPath)
if output, err := stsCmd.CombinedOutput(); err != nil {
// Ignore "no resources found" errors - namespace may only have deployments
if !strings.Contains(string(output), "no resources found") {
return fmt.Errorf("failed to restart statefulsets: %w\nOutput: %s", err, string(output))
}
@@ -1101,6 +1112,19 @@ func (m *Manager) Restart(inst *instance.Instance, appName string) error {
return nil
}
// hasConfigMapChanges reports whether kubectl apply output indicates any ConfigMaps were updated.
// On first deploy all resources are "created", not "configured", so this correctly returns false
// and avoids a redundant restart before any pods exist.
func hasConfigMapChanges(output []byte) bool {
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "configmap/") && strings.HasSuffix(line, " configured") {
return true
}
}
return false
}
// protectedNamespaces that must never be deleted
var protectedNamespaces = map[string]bool{
"kube-system": true, "kube-public": true,
@@ -1121,14 +1145,14 @@ func (m *Manager) namespaceSharedByOtherApp(inst *instance.Instance, appName, na
return false
}
// Delete removes an app from the cluster and configuration
// Delete removes an app from the cluster and configuration.
// Local filesystem cleanup always runs, even if k8s deletion fails.
func (m *Manager) Delete(inst *instance.Instance, appName string) error {
slog.Info("deleting app", "component", "apps", "instance", inst.Name, "app", appName)
kubeconfigPath := inst.KubeconfigPath()
appDir := m.getAppPackageDir(inst.Name, appName)
appConfigFile := m.getAppConfigFile(inst.Name, appName)
secretsFile := m.getAppSecretsFile(inst.Name, appName)
appConfigDir := tools.GetAppConfigDirPath(m.dataDir, inst.Name, appName)
if !storage.FileExists(appDir) {
return fmt.Errorf("app %s not found in instance", appName)
@@ -1136,46 +1160,42 @@ func (m *Manager) Delete(inst *instance.Instance, appName string) error {
namespace := m.ResolveNamespace(inst, appName)
// For protected or shared namespaces, only delete this app's resources (not the namespace)
// Attempt k8s cleanup; track error but don't return early so local files are always cleaned up.
var k8sErr error
if protectedNamespaces[namespace] || m.namespaceSharedByOtherApp(inst, appName, namespace) {
deleteCmd := exec.Command("kubectl", "delete", "-k", appDir, "--ignore-not-found")
tools.WithKubeconfig(deleteCmd, kubeconfigPath)
output, err := deleteCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to delete app resources: %w\nOutput: %s", err, string(output))
if output, err := deleteCmd.CombinedOutput(); err != nil {
k8sErr = fmt.Errorf("failed to delete app resources: %w\nOutput: %s", err, string(output))
slog.Error("k8s resource deletion failed", "component", "apps", "instance", inst.Name, "app", appName, "error", k8sErr)
}
} else {
// App owns this namespace exclusively - delete the whole namespace
deleteNsCmd := exec.Command("kubectl", "delete", "namespace", namespace, "--ignore-not-found")
tools.WithKubeconfig(deleteNsCmd, kubeconfigPath)
output, err := deleteNsCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to delete namespace: %w\nOutput: %s", err, string(output))
if output, err := deleteNsCmd.CombinedOutput(); err != nil {
k8sErr = fmt.Errorf("failed to delete namespace: %w\nOutput: %s", err, string(output))
slog.Error("namespace deletion failed", "component", "apps", "instance", inst.Name, "app", appName, "error", k8sErr)
} else {
// Wait for namespace deletion to complete (timeout after 60s)
waitCmd := exec.Command("kubectl", "wait", "--for=delete", "namespace", namespace, "--timeout=60s")
tools.WithKubeconfig(waitCmd, kubeconfigPath)
_, _ = waitCmd.CombinedOutput() // Ignore errors - namespace might not exist
}
// Wait for namespace deletion to complete (timeout after 60s)
waitCmd := exec.Command("kubectl", "wait", "--for=delete", "namespace", namespace, "--timeout=60s")
tools.WithKubeconfig(waitCmd, kubeconfigPath)
_, _ = waitCmd.CombinedOutput() // Ignore errors - namespace might not exist
}
// Delete local app package directory
// Always clean up local files regardless of k8s outcome.
if err := os.RemoveAll(appDir); err != nil {
return fmt.Errorf("failed to delete local app directory: %w", err)
}
// Remove app config
if err := os.Remove(appConfigFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove app config: %w", err)
}
// Remove app secrets
if err := os.Remove(secretsFile); err != nil && !os.IsNotExist(err) {
slog.Warn("failed to remove app secrets", "component", "apps", "app", appName, "error", err)
// Remove entire app config directory (config.yaml, secrets.yaml, schedule.yaml, etc.)
if err := os.RemoveAll(appConfigDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove app config directory: %w", err)
}
slog.Info("app deleted", "component", "apps", "instance", inst.Name, "app", appName)
return nil
return k8sErr
}
// GetStatus returns the status of a deployed app

View File

@@ -1899,3 +1899,69 @@ func TestEnsureDefaultSecretsSucceedsWithEmptyFile(t *testing.T) {
t.Errorf("expected secrets file to contain 'dbPassword', got: %s", string(data))
}
}
// TestDeleteCleansUpConfigDirectory ensures that Delete removes the entire
// config/apps/<app>/ directory, including schedule.yaml. Previously only
// config.yaml and secrets.yaml were removed, leaving schedule.yaml orphaned
// and causing deleted apps to appear in backup schedule lists.
func TestDeleteCleansUpConfigDirectory(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "delete-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
dataDir := filepath.Join(tmpDir, "data")
appsDir := filepath.Join(tmpDir, "apps")
instanceName := "test-instance"
appName := "deleteapp"
instancePath := filepath.Join(dataDir, "instances", instanceName)
// Create the app data directory (what Delete checks for existence)
appDataDir := filepath.Join(instancePath, "data", "apps", appName)
if err := storage.EnsureDir(appDataDir, 0755); err != nil {
t.Fatal(err)
}
// Create a minimal manifest so Delete can proceed
manifestYAML := "name: deleteapp\ndescription: test\nversion: 1.0.0\n"
if err := os.WriteFile(filepath.Join(appDataDir, "manifest.yaml"), []byte(manifestYAML), 0644); err != nil {
t.Fatal(err)
}
// Create the config directory with config.yaml, secrets.yaml, AND schedule.yaml
appConfigDir := filepath.Join(instancePath, "config", "apps", appName)
if err := storage.EnsureDir(appConfigDir, 0755); err != nil {
t.Fatal(err)
}
for _, fname := range []string{"config.yaml", "secrets.yaml", "schedule.yaml"} {
if err := os.WriteFile(filepath.Join(appConfigDir, fname), []byte(""), 0644); err != nil {
t.Fatal(err)
}
}
// Create instance config and kubeconfig so Delete can resolve the namespace
if err := storage.EnsureDir(filepath.Join(instancePath, "config"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(instancePath, "config", "instance.yaml"), []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
t.Fatal(err)
}
mgr := NewManager(dataDir, appsDir)
inst := instance.NewInstance(instanceName, dataDir)
// Delete will attempt kubectl to remove k8s resources, which will fail in
// test. We only care that the local filesystem cleanup happens correctly, so
// we verify the config directory is gone regardless of the kubectl error.
_ = mgr.Delete(inst, appName)
// The entire config directory (including schedule.yaml) must be removed
if storage.FileExists(appConfigDir) {
t.Errorf("config/apps/%s/ should be removed after Delete, but it still exists", appName)
}
if storage.FileExists(filepath.Join(appConfigDir, "schedule.yaml")) {
t.Errorf("schedule.yaml should be removed after Delete")
}
}

View File

@@ -953,3 +953,49 @@ func TestPodListStatus_PodNotRunning(t *testing.T) {
t.Errorf("expected 'unhealthy', got %q", got)
}
}
// ---------------------------------------------------------------------------
// hasConfigMapChanges
// ---------------------------------------------------------------------------
func TestHasConfigMapChanges_Configured(t *testing.T) {
output := []byte("namespace/myapp unchanged\nconfigmap/myapp-config configured\ndeployment.apps/myapp unchanged\n")
if !hasConfigMapChanges(output) {
t.Error("expected true when a ConfigMap is configured")
}
}
func TestHasConfigMapChanges_Unchanged(t *testing.T) {
output := []byte("namespace/myapp unchanged\nconfigmap/myapp-config unchanged\ndeployment.apps/myapp configured\n")
if hasConfigMapChanges(output) {
t.Error("expected false when ConfigMap is unchanged")
}
}
func TestHasConfigMapChanges_Created(t *testing.T) {
// First deploy: all resources are "created", no pods exist yet — no restart needed
output := []byte("namespace/myapp created\nconfigmap/myapp-config created\ndeployment.apps/myapp created\n")
if hasConfigMapChanges(output) {
t.Error("expected false when ConfigMap is created (first deploy)")
}
}
func TestHasConfigMapChanges_NoConfigMap(t *testing.T) {
output := []byte("deployment.apps/myapp configured\nservice/myapp unchanged\n")
if hasConfigMapChanges(output) {
t.Error("expected false when no ConfigMap in output")
}
}
func TestHasConfigMapChanges_Empty(t *testing.T) {
if hasConfigMapChanges([]byte{}) {
t.Error("expected false for empty output")
}
}
func TestHasConfigMapChanges_MultipleConfigMaps_AnyConfigured(t *testing.T) {
output := []byte("configmap/app-config unchanged\nconfigmap/app-env configured\n")
if !hasConfigMapChanges(output) {
t.Error("expected true when any ConfigMap is configured")
}
}

View File

@@ -76,6 +76,13 @@ func (l *LonghornNativeStrategy) Backup(plan *btypes.RecoveryPlan, dest btypes.B
continue
}
// Skip non-Longhorn PVCs (e.g. NFS provisioner volumes)
storageClass := l.getPVCStorageClass(kubeconfigPath, sourceNamespace, pvcName)
if !strings.HasPrefix(storageClass, "longhorn") {
slog.Info("skipping non-Longhorn PVC", "component", "longhorn", "pvc", pvcName, "storageClass", storageClass)
continue
}
volumeName, err := l.getVolumeNameFromPVC(kubeconfigPath, sourceNamespace, pvcName)
if err != nil {
return fmt.Errorf("failed to get volume name for PVC %s: %w", pvcName, err)
@@ -520,6 +527,17 @@ func (l *LonghornNativeStrategy) getPVCAccessMode(kubeconfigPath, namespace, pvc
return "ReadWriteOnce"
}
func (l *LonghornNativeStrategy) getPVCStorageClass(kubeconfigPath, namespace, pvcName string) string {
cmd := exec.Command("kubectl", "get", "pvc", "-n", namespace, pvcName,
"-o", "jsonpath={.spec.storageClassName}")
tools.WithKubeconfig(cmd, kubeconfigPath)
if output, err := cmd.Output(); err == nil && len(output) > 0 {
return string(output)
}
return ""
}
func (l *LonghornNativeStrategy) getVolumeNameFromPVC(kubeconfigPath, namespace, pvcName string) (string, error) {
cmd := exec.Command("kubectl", "get", "pvc", "-n", namespace, pvcName,
"-o", "jsonpath={.spec.volumeName}")

View File

@@ -93,3 +93,28 @@ decidim-data-restore-20260609t151938z nfs://192.168.8.222:/storage/nfs/test-clou
assert.Equal(t, "writefreely-data-restore-202606-7b566524", found,
"should find the volume matching the backup URL even when Longhorn renamed it")
}
// TestGetPVCStorageClassFilter verifies that the storage class check correctly
// distinguishes Longhorn from non-Longhorn storage classes.
func TestGetPVCStorageClassFilter(t *testing.T) {
tests := []struct {
storageClass string
shouldBackup bool
}{
{"longhorn", true},
{"longhorn-1replica", true},
{"longhorn-static", true},
{"nfs", false},
{"hostpath", false},
{"local-path", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.storageClass, func(t *testing.T) {
result := strings.HasPrefix(tt.storageClass, "longhorn")
assert.Equal(t, tt.shouldBackup, result,
"storageClass=%q should backup=%v", tt.storageClass, tt.shouldBackup)
})
}
}

View File

@@ -197,25 +197,11 @@ func GetInstanceDiscoveryPath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName, "cache")
}
// ---------------------------------------------------------------------------
// Remaining legacy helpers (unchanged paths, kept for compatibility)
// ---------------------------------------------------------------------------
// GetInstancePath returns the path to an instance directory
func GetInstancePath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName)
}
// GetInstanceTalosPath returns the path to an instance's talos directory (legacy layout)
func GetInstanceTalosPath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName, "talos")
}
// GetInstanceAssetsPath returns the path to an instance's assets directory
func GetInstanceAssetsPath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName, "assets")
}
// GetInstancesPath returns the path to the instances directory
func GetInstancesPath(dataDir string) string {
return filepath.Join(dataDir, "instances")

View File

@@ -345,22 +345,6 @@ func TestGetInstanceDiscoveryPath(t *testing.T) {
}
}
func TestGetInstanceTalosPath(t *testing.T) {
got := GetInstanceTalosPath("/data", "test-cloud")
want := "/data/instances/test-cloud/talos"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestGetInstanceAssetsPath(t *testing.T) {
got := GetInstanceAssetsPath("/data", "test-cloud")
want := "/data/instances/test-cloud/assets"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestPathsAreUnderInstance(t *testing.T) {
dataDir := "/data"
instanceName := "test-cloud"
@@ -387,8 +371,6 @@ func TestPathsAreUnderInstance(t *testing.T) {
"NodeSetupCache": GetNodeSetupCachePath(dataDir, instanceName),
"DiscoveryCache": GetDiscoveryCachePath(dataDir, instanceName),
"InstanceDiscovery": GetInstanceDiscoveryPath(dataDir, instanceName),
"InstanceTalos": GetInstanceTalosPath(dataDir, instanceName),
"InstanceAssets": GetInstanceAssetsPath(dataDir, instanceName),
}
for name, p := range paths {

View File

@@ -297,6 +297,254 @@ export function AppDetailPanel({
{getStatusBadge(appDetails.status)}
</div>
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-4">
{/* Lifecycle actions */}
<div className="flex flex-wrap gap-2">
{appDetails.status === 'added' && (
<>
<Button size="sm" variant="outline" onClick={() => onConfigure(appName)}>
<Settings className="h-4 w-4 mr-2" />
Configure
</Button>
<Button size="sm" onClick={() => onDeploy(appName)} disabled={isDeploying}>
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
Deploy
</Button>
</>
)}
{appDetails.status !== 'added' && appDetails.status !== 'not-added' && (
<>
<Button size="sm" variant="outline" onClick={() => onConfigure(appName)}>
<Settings className="h-4 w-4 mr-2" />
Reconfigure
</Button>
<Button size="sm" variant="outline" onClick={() => onDeploy(appName)} disabled={isDeploying}>
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
Redeploy
</Button>
<Button size="sm" variant="outline" onClick={() => onRestart(appName)} disabled={isRestarting}>
{isRestarting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RotateCcw className="h-4 w-4 mr-2" />}
Restart
</Button>
</>
)}
{updateAvailable && sourceUpdate?.upgradeBlocked && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="border-red-500 text-red-700 dark:text-red-400"
disabled
>
<AlertCircle className="h-4 w-4 mr-2" />
Upgrade blocked
</Button>
{sourceUpdate.upgradeNotes && (
<span className="text-xs text-muted-foreground max-w-xs truncate" title={sourceUpdate.upgradeNotes}>
{sourceUpdate.upgradeNotes}
</span>
)}
</div>
)}
{updateAvailable && availableVersion && !sourceUpdate?.upgradeBlocked && (
<Button
size="sm"
variant="outline"
className="border-amber-500 text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/20"
onClick={() => onUpdate(appName)}
disabled={isUpdating}
>
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
Update to {availableVersion}
{(sourceUpdate?.upgradeSteps ?? 0) > 1 && ` (${sourceUpdate?.upgradeSteps} steps)`}
</Button>
)}
</div>
{/* Backup status */}
{category !== 'services' && appDetails.status !== 'added' && appDetails.status !== 'not-added' && (
<div className="flex items-center gap-3 border-l pl-4">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{appBackupHealth?.lastBackup
? `Backed up ${formatRelativeTime(appBackupHealth.lastBackup)}`
: 'No backup'}
</span>
<Button size="sm" variant="outline" onClick={() => createBackup()} disabled={isCreatingBackup}>
{isCreatingBackup ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Archive className="h-4 w-4 mr-2" />}
Back Up Now
</Button>
<Button size="sm" variant="ghost" onClick={() => navigate(`/instances/${instanceName}/backups/${appName}`)}>
View backups
</Button>
</div>
)}
{/* Scripts */}
{appDetails.manifest?.scripts && appDetails.manifest.scripts.length > 0 && (
<div className="flex flex-wrap gap-2 border-l pl-4">
{appDetails.manifest.scripts.map((script) => (
<Button
key={script.name}
size="sm"
variant="outline"
title={script.description}
onClick={() => {
if (script.params && script.params.length > 0) {
setPendingScript({ name: script.name, params: {} });
} else {
runScript(script.name);
}
}}
disabled={runningScripts.has(script.name)}
>
{runningScripts.has(script.name) ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Terminal className="h-4 w-4 mr-2" />}
{script.name}
</Button>
))}
</div>
)}
{/* Script param form */}
{pendingScript && (() => {
const script = appDetails.manifest?.scripts?.find(s => s.name === pendingScript.name);
if (!script) return null;
return (
<Card className="border-l-4 border-l-blue-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Terminal className="h-4 w-4" />
{script.name}
</CardTitle>
{script.description && <CardDescription>{script.description}</CardDescription>}
</CardHeader>
<CardContent className="space-y-3">
{script.params?.map((param) => (
<div key={param.name}>
<Label htmlFor={`param-${param.name}`} className="text-xs">
{param.name}{param.required && <span className="text-red-500 ml-1">*</span>}
</Label>
{param.description && <p className="text-xs text-muted-foreground mb-1">{param.description}</p>}
<Input
id={`param-${param.name}`}
className="mt-1"
value={pendingScript.params[param.name] ?? ''}
onChange={(e) => setPendingScript(prev => prev ? {
...prev,
params: { ...prev.params, [param.name]: e.target.value }
} : null)}
/>
</div>
))}
<div className="flex gap-2 pt-1">
<Button
size="sm"
onClick={() => {
const ps = pendingScript;
setPendingScript(null);
runScript(ps.name, ps.params);
}}
disabled={script.params?.some(p => p.required && !pendingScript.params[p.name])}
>
Run
</Button>
<Button size="sm" variant="outline" onClick={() => setPendingScript(null)}>
Cancel
</Button>
</div>
</CardContent>
</Card>
);
})()}
{/* Danger zone */}
<div className="flex gap-2 border-l pl-4">
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(appName)}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
Delete
</Button>
</div>
</div>
{/* Drift summary */}
{drift && (drift.source || drift.compilation || drift.deploy) && (
<Card className="border-l-4 border-l-amber-500">
<CardContent className="py-3 px-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-amber-500 mt-0.5 shrink-0" />
<div className="space-y-1">
{drift.source?.drifted && (
<p className="text-sm">
{drift.source.currentVersion && drift.source.availableVersion
? `Update available: ${drift.source.currentVersion} \u2192 ${drift.source.availableVersion}`
: drift.source.reason}
</p>
)}
{drift.compilation?.drifted && (
<p className="text-sm">{drift.compilation.reason}</p>
)}
{drift.deploy?.drifted && (
<p className="text-sm">{drift.deploy.reason}</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Script output */}
{scriptOutput && (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Terminal className="h-4 w-4" />
{scriptOutput.name}
{scriptOutput.status === 'running' && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{scriptOutput.status === 'completed' && (
<CheckCircle className="h-3 w-3 text-green-600" />
)}
{scriptOutput.status === 'failed' && (
<AlertCircle className="h-3 w-3 text-red-600" />
)}
</CardTitle>
{scriptOutput.status !== 'running' && (
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={() => setScriptOutput(null)}
>
Dismiss
</Button>
)}
</div>
</CardHeader>
<CardContent>
<pre
ref={scriptOutputRef}
className="bg-muted rounded-md p-3 text-xs font-mono overflow-auto max-h-64 whitespace-pre-wrap"
>
{scriptOutput.lines.length > 0
? scriptOutput.lines.join('\n')
: scriptOutput.status === 'running'
? 'Waiting for output...'
: 'No output'}
</pre>
</CardContent>
</Card>
)}
<Tabs defaultValue="overview" value={activeTab} onValueChange={setActiveTab} className="md:flex-1 md:min-h-0">
<TabsList className="grid w-full grid-cols-6 mb-6 shrink-0">
<TabsTrigger value="overview">
@@ -327,256 +575,8 @@ export function AppDetailPanel({
{/* Overview Tab */}
<TabsContent value="overview" className="flex flex-col gap-6 md:min-h-0">
{/* Action Buttons */}
<div className="shrink-0 flex flex-wrap items-center gap-4">
{/* Lifecycle actions */}
<div className="flex flex-wrap gap-2">
{appDetails.status === 'added' && (
<>
<Button size="sm" variant="outline" onClick={() => onConfigure(appName)}>
<Settings className="h-4 w-4 mr-2" />
Configure
</Button>
<Button size="sm" onClick={() => onDeploy(appName)} disabled={isDeploying}>
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
Deploy
</Button>
</>
)}
{appDetails.status !== 'added' && appDetails.status !== 'not-added' && (
<>
<Button size="sm" variant="outline" onClick={() => onConfigure(appName)}>
<Settings className="h-4 w-4 mr-2" />
Reconfigure
</Button>
<Button size="sm" variant="outline" onClick={() => onDeploy(appName)} disabled={isDeploying}>
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
Redeploy
</Button>
<Button size="sm" variant="outline" onClick={() => onRestart(appName)} disabled={isRestarting}>
{isRestarting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RotateCcw className="h-4 w-4 mr-2" />}
Restart
</Button>
</>
)}
{updateAvailable && sourceUpdate?.upgradeBlocked && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="border-red-500 text-red-700 dark:text-red-400"
disabled
>
<AlertCircle className="h-4 w-4 mr-2" />
Upgrade blocked
</Button>
{sourceUpdate.upgradeNotes && (
<span className="text-xs text-muted-foreground max-w-xs truncate" title={sourceUpdate.upgradeNotes}>
{sourceUpdate.upgradeNotes}
</span>
)}
</div>
)}
{updateAvailable && !sourceUpdate?.upgradeBlocked && (
<Button
size="sm"
variant="outline"
className="border-amber-500 text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/20"
onClick={() => onUpdate(appName)}
disabled={isUpdating}
>
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
Update to {availableVersion}
{(sourceUpdate?.upgradeSteps ?? 0) > 1 && ` (${sourceUpdate?.upgradeSteps} steps)`}
</Button>
)}
</div>
{/* Backup status */}
{category !== 'services' && appDetails.status !== 'added' && appDetails.status !== 'not-added' && (
<div className="flex items-center gap-3 border-l pl-4">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{appBackupHealth?.lastBackup
? `Backed up ${formatRelativeTime(appBackupHealth.lastBackup)}`
: 'No backup'}
</span>
<Button size="sm" variant="outline" onClick={() => createBackup()} disabled={isCreatingBackup}>
{isCreatingBackup ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Archive className="h-4 w-4 mr-2" />}
Back Up Now
</Button>
<Button size="sm" variant="ghost" onClick={() => navigate(`/instances/${instanceName}/backups/${appName}`)}>
View backups
</Button>
</div>
)}
{/* Scripts */}
{appDetails.manifest?.scripts && appDetails.manifest.scripts.length > 0 && (
<div className="flex flex-wrap gap-2 border-l pl-4">
{appDetails.manifest.scripts.map((script) => (
<Button
key={script.name}
size="sm"
variant="outline"
title={script.description}
onClick={() => {
if (script.params && script.params.length > 0) {
setPendingScript({ name: script.name, params: {} });
} else {
runScript(script.name);
}
}}
disabled={runningScripts.has(script.name)}
>
{runningScripts.has(script.name) ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Terminal className="h-4 w-4 mr-2" />}
{script.name}
</Button>
))}
</div>
)}
{/* Script param form */}
{pendingScript && (() => {
const script = appDetails.manifest?.scripts?.find(s => s.name === pendingScript.name);
if (!script) return null;
return (
<Card className="shrink-0 border-l-4 border-l-blue-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Terminal className="h-4 w-4" />
{script.name}
</CardTitle>
{script.description && <CardDescription>{script.description}</CardDescription>}
</CardHeader>
<CardContent className="space-y-3">
{script.params?.map((param) => (
<div key={param.name}>
<Label htmlFor={`param-${param.name}`} className="text-xs">
{param.name}{param.required && <span className="text-red-500 ml-1">*</span>}
</Label>
{param.description && <p className="text-xs text-muted-foreground mb-1">{param.description}</p>}
<Input
id={`param-${param.name}`}
className="mt-1"
value={pendingScript.params[param.name] ?? ''}
onChange={(e) => setPendingScript(prev => prev ? {
...prev,
params: { ...prev.params, [param.name]: e.target.value }
} : null)}
/>
</div>
))}
<div className="flex gap-2 pt-1">
<Button
size="sm"
onClick={() => {
const ps = pendingScript;
setPendingScript(null);
runScript(ps.name, ps.params);
}}
disabled={script.params?.some(p => p.required && !pendingScript.params[p.name])}
>
Run
</Button>
<Button size="sm" variant="outline" onClick={() => setPendingScript(null)}>
Cancel
</Button>
</div>
</CardContent>
</Card>
);
})()}
{/* Danger zone */}
<div className="flex gap-2 border-l pl-4">
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(appName)}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
Delete
</Button>
</div>
</div>
{/* Drift summary */}
{drift && (drift.source || drift.compilation || drift.deploy) && (
<Card className="shrink-0 border-l-4 border-l-amber-500">
<CardContent className="py-3 px-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-amber-500 mt-0.5 shrink-0" />
<div className="space-y-1">
{drift.source?.drifted && (
<p className="text-sm">
{drift.source.currentVersion && drift.source.availableVersion
? `Update available: ${drift.source.currentVersion} \u2192 ${drift.source.availableVersion}`
: drift.source.reason}
</p>
)}
{drift.compilation?.drifted && (
<p className="text-sm">{drift.compilation.reason}</p>
)}
{drift.deploy?.drifted && (
<p className="text-sm">{drift.deploy.reason}</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Script output */}
{scriptOutput && (
<Card className="shrink-0">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Terminal className="h-4 w-4" />
{scriptOutput.name}
{scriptOutput.status === 'running' && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{scriptOutput.status === 'completed' && (
<CheckCircle className="h-3 w-3 text-green-600" />
)}
{scriptOutput.status === 'failed' && (
<AlertCircle className="h-3 w-3 text-red-600" />
)}
</CardTitle>
{scriptOutput.status !== 'running' && (
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={() => setScriptOutput(null)}
>
Dismiss
</Button>
)}
</div>
</CardHeader>
<CardContent>
<pre
ref={scriptOutputRef}
className="bg-muted rounded-md p-3 text-xs font-mono overflow-auto max-h-64 whitespace-pre-wrap"
>
{scriptOutput.lines.length > 0
? scriptOutput.lines.join('\n')
: scriptOutput.status === 'running'
? 'Waiting for output...'
: 'No output'}
</pre>
</CardContent>
</Card>
)}
{/* App Information */}
<Card className="shrink-0">
<Card>
<CardHeader>
<CardTitle className="text-lg">Application Information</CardTitle>
</CardHeader>