From 9ac643a50f65cbbacce2b990ae70418129cb562c Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 24 May 2026 03:59:36 +0000 Subject: [PATCH] First version of app upgrade. --- api/internal/api/v1/handlers.go | 1 + api/internal/api/v1/handlers_apps.go | 26 +- api/internal/api/v1/requests.go | 1 + api/internal/apps/apps.go | 267 ++++++++- api/internal/apps/apps_test.go | 16 +- api/internal/apps/drift.go | 16 +- api/internal/apps/drift_test.go | 10 +- api/internal/apps/models.go | 32 ++ api/internal/apps/upgrade.go | 261 +++++++++ api/internal/apps/upgrade_test.go | 602 +++++++++++++++++++++ api/test/e2e/tests/08-upgrade.sh | 105 ++++ cli/cmd/app.go | 60 +- web/src/components/apps/AppDetailPanel.tsx | 22 +- web/src/services/api/apps.ts | 5 + web/src/services/api/types/app.ts | 16 + 15 files changed, 1389 insertions(+), 51 deletions(-) create mode 100644 api/internal/apps/upgrade.go create mode 100644 api/internal/apps/upgrade_test.go create mode 100644 api/test/e2e/tests/08-upgrade.sh diff --git a/api/internal/api/v1/handlers.go b/api/internal/api/v1/handlers.go index d05d67a..7261719 100644 --- a/api/internal/api/v1/handlers.go +++ b/api/internal/api/v1/handlers.go @@ -201,6 +201,7 @@ func (api *API) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/v1/instances/{name}/apps/{app}/fetch", api.AppsFetch).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/restart", api.AppsRestart).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/update", api.AppsUpdate).Methods("POST") + r.HandleFunc("/api/v1/instances/{name}/apps/{app}/upgrade-plan", api.AppsGetUpgradePlan).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/eject", api.AppsEject).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/config", api.AppsGetConfig).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/config", api.AppsUpdateConfig).Methods("PATCH") diff --git a/api/internal/api/v1/handlers_apps.go b/api/internal/api/v1/handlers_apps.go index eae469a..ae2a9c0 100644 --- a/api/internal/api/v1/handlers_apps.go +++ b/api/internal/api/v1/handlers_apps.go @@ -83,7 +83,7 @@ func (api *API) AppsAdd(w http.ResponseWriter, r *http.Request) { } appsMgr := apps.NewManager(api.dataDir, api.appsDir) - if err := appsMgr.Add(instanceName, req.Name, req.Config, req.RequiredAppMappings); err != nil { + if err := appsMgr.Add(instanceName, req.Name, req.Version, req.Config, req.RequiredAppMappings); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add app: %v", err)) return } @@ -137,17 +137,33 @@ func (api *API) AppsDelete(w http.ResponseWriter, r *http.Request) { }) } -// AppsUpdate updates an app from its source +// AppsUpdate updates an app from its source, using the upgrade-aware path func (api *API) AppsUpdate(w http.ResponseWriter, r *http.Request) { instanceName := GetInstanceName(r) appName := GetAppName(r) - api.startAppOperation(w, instanceName, appName, "update_app", "App updated", - func(mgr *apps.Manager, instance, app string) error { - return mgr.Update(instance, app) + api.StartAsyncOperationWithBroadcaster(w, instanceName, "update_app", appName, + func(opsMgr *operations.Manager, opID string, broadcaster *operations.Broadcaster) error { + appsMgr := apps.NewManager(api.dataDir, api.appsDir) + return appsMgr.Upgrade(instanceName, appName, opID, broadcaster) }) } +// AppsGetUpgradePlan returns the computed upgrade plan for an app +func (api *API) AppsGetUpgradePlan(w http.ResponseWriter, r *http.Request) { + instanceName := GetInstanceName(r) + appName := GetAppName(r) + + appsMgr := apps.NewManager(api.dataDir, api.appsDir) + plan, err := appsMgr.GetUpgradePlan(instanceName, appName) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to compute upgrade plan: %v", err)) + return + } + + respondJSON(w, http.StatusOK, plan) +} + // AppsEject converts an app from package-managed to custom func (api *API) AppsEject(w http.ResponseWriter, r *http.Request) { instanceName := GetInstanceName(r) diff --git a/api/internal/api/v1/requests.go b/api/internal/api/v1/requests.go index 442de14..23f3071 100644 --- a/api/internal/api/v1/requests.go +++ b/api/internal/api/v1/requests.go @@ -26,6 +26,7 @@ type IPRequest struct { // AppAddRequest is the request body for adding an app to an instance. type AppAddRequest struct { Name string `json:"name"` + Version string `json:"version,omitempty"` Config map[string]interface{} `json:"config"` RequiredAppMappings map[string]string `json:"requiredAppMappings"` Fetch bool `json:"fetch,omitempty"` diff --git a/api/internal/apps/apps.go b/api/internal/apps/apps.go index 76fcad1..ae23c40 100644 --- a/api/internal/apps/apps.go +++ b/api/internal/apps/apps.go @@ -101,7 +101,7 @@ func (m *Manager) ListAvailable(category string) ([]App, error) { apps := []App{} for _, entry := range entries { - if !entry.IsDir() { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { continue } @@ -528,10 +528,17 @@ func setNestedConfig(yq *tools.YQ, configFile, basePath string, value interface{ } // Add adds an app to the instance configuration -func (m *Manager) Add(instanceName, appName string, config map[string]interface{}, requiredAppMappings map[string]string) error { - // 1. Verify app exists - manifestPath := filepath.Join(m.appsDir, appName, "manifest.yaml") +func (m *Manager) Add(instanceName, appName, version string, config map[string]interface{}, requiredAppMappings map[string]string) error { + // 1. Verify app exists, optionally at a specific version + sourceAppDir := filepath.Join(m.appsDir, appName) + if version != "" { + sourceAppDir = filepath.Join(m.appsDir, appName, ".versions", version) + } + manifestPath := filepath.Join(sourceAppDir, "manifest.yaml") if !storage.FileExists(manifestPath) { + if version != "" { + return fmt.Errorf("app %s version %s not found at %s", appName, version, manifestPath) + } return fmt.Errorf("app %s not found at %s", appName, manifestPath) } @@ -580,7 +587,7 @@ func (m *Manager) Add(instanceName, appName string, config map[string]interface{ // 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(m.appsDir, appName, "manifest.yaml") + sourceManifestPath := filepath.Join(sourceAppDir, "manifest.yaml") if err := processConfigInOrder(sourceManifestPath, appName, configFile); err != nil { return fmt.Errorf("failed to process config in order: %w", err) } @@ -613,7 +620,6 @@ func (m *Manager) Add(instanceName, appName string, config map[string]interface{ } // 5. Copy source files to .package directory first - sourceAppDir := filepath.Join(m.appsDir, appName) entries, err := os.ReadDir(sourceAppDir) if err != nil { return fmt.Errorf("failed to read app directory: %w", err) @@ -635,8 +641,11 @@ func (m *Manager) Add(instanceName, appName string, config map[string]interface{ } } - // Copy all source files to .package directory + // Copy all source files to .package directory (skip .versions/) for _, entry := range entries { + if entry.Name() == ".versions" { + continue + } sourcePath := filepath.Join(sourceAppDir, entry.Name()) packagePath := filepath.Join(packageDir, entry.Name()) @@ -1503,58 +1512,74 @@ func (m *Manager) Update(instanceName, appName string) error { var sourceAppDir string switch sourceParts[0] { case "file": - // Local filesystem path sourceAppDir = sourceParts[1] case "git+https", "git+http", "git+ssh": - // Git repository - not yet implemented return fmt.Errorf("git source not yet supported: %s", manifest.Source) default: return fmt.Errorf("unsupported source protocol: %s", sourceParts[0]) } + return m.updateFromSource(instanceName, appName, sourceAppDir, manifest.Source) +} + +// updateFromSource performs the core update logic: copies files from sourceDir +// to .package, merges config, preserves instance-specific manifest fields, and recompiles. +// preserveSource is the source URI to write to the manifest (always the top-level app dir, not waypoints). +func (m *Manager) updateFromSource(instanceName, appName, sourceDir, preserveSource string) error { + instancePath := tools.GetInstancePath(m.dataDir, instanceName) + appDestDir := filepath.Join(instancePath, "apps", appName) + packageDir := filepath.Join(appDestDir, ".package") + manifestPath := filepath.Join(appDestDir, "manifest.yaml") + + // Read current manifest for preserving instance-specific fields + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("failed to read manifest: %w", err) + } + var manifest AppManifest + if err := yaml.Unmarshal(manifestData, &manifest); err != nil { + return fmt.Errorf("failed to parse manifest: %w", err) + } + // Copy new version to temp directory tempDir := filepath.Join(appDestDir, ".package.new") if err := storage.EnsureDir(tempDir, 0755); err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } - defer os.RemoveAll(tempDir) // Clean up temp dir + defer os.RemoveAll(tempDir) - // Copy from source - entries, err := os.ReadDir(sourceAppDir) + entries, err := os.ReadDir(sourceDir) if err != nil { return fmt.Errorf("failed to read source directory: %w", err) } for _, entry := range entries { + if entry.Name() == ".versions" { + continue + } if entry.IsDir() { - subSrcDir := filepath.Join(sourceAppDir, entry.Name()) - subDstDir := filepath.Join(tempDir, entry.Name()) - if err := copyDir(subSrcDir, subDstDir); err != nil { + if err := copyDir(filepath.Join(sourceDir, entry.Name()), filepath.Join(tempDir, entry.Name())); err != nil { return fmt.Errorf("failed to copy directory %s: %w", entry.Name(), err) } continue } - sourcePath := filepath.Join(sourceAppDir, entry.Name()) - tempPath := filepath.Join(tempDir, entry.Name()) - - data, err := os.ReadFile(sourcePath) + data, err := os.ReadFile(filepath.Join(sourceDir, entry.Name())) if err != nil { return fmt.Errorf("failed to read %s: %w", entry.Name(), err) } - if err := storage.WriteFile(tempPath, data, 0644); err != nil { + if err := storage.WriteFile(filepath.Join(tempDir, entry.Name()), data, 0644); err != nil { return fmt.Errorf("failed to write %s: %w", entry.Name(), err) } } // Replace .package with new version, keeping old as backup oldPackageDir := packageDir + ".old" - os.RemoveAll(oldPackageDir) // Clean up any leftover backup + os.RemoveAll(oldPackageDir) if err := os.Rename(packageDir, oldPackageDir); err != nil { return fmt.Errorf("failed to backup old package: %w", err) } if err := os.Rename(tempDir, packageDir); err != nil { - // Restore backup on failure os.Rename(oldPackageDir, packageDir) return fmt.Errorf("failed to update package: %w", err) } @@ -1562,7 +1587,6 @@ func (m *Manager) Update(instanceName, appName string) error { configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName) secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName) - // rollback restores the old package if anything fails after the swap rollback := func() { os.RemoveAll(packageDir) os.Rename(oldPackageDir, packageDir) @@ -1598,7 +1622,7 @@ func (m *Manager) Update(instanceName, appName string) error { } // Update local manifest with new version while preserving source and installedAs - newManifest.Source = manifest.Source + newManifest.Source = preserveSource if len(manifest.Requires) > 0 { installedAsMap := make(map[string]string) for _, req := range manifest.Requires { @@ -1643,6 +1667,191 @@ func (m *Manager) Update(instanceName, appName string) error { return nil } +// Upgrade performs an upgrade-aware update: computes the upgrade plan, then executes +// each step (update + deploy) in sequence. Falls back to a single-step direct update +// for apps without upgrade metadata. +func (m *Manager) Upgrade(instanceName, appName, opID string, broadcaster *operations.Broadcaster) error { + instancePath := tools.GetInstancePath(m.dataDir, instanceName) + appDestDir := filepath.Join(instancePath, "apps", appName) + manifestPath := filepath.Join(appDestDir, "manifest.yaml") + + // Read installed manifest + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("failed to read manifest: %w", err) + } + var manifest AppManifest + if err := yaml.Unmarshal(manifestData, &manifest); err != nil { + return fmt.Errorf("failed to parse manifest: %w", err) + } + + if manifest.Source == "" { + return fmt.Errorf("app %s has no source (ejected or custom)", appName) + } + + // Resolve the apps directory from the source URI + // Source is like "file:///path/to/wild-directory/appname" — we need the parent + sourceDir := parseSourceDir(manifest.Source) + if sourceDir == "" { + return fmt.Errorf("unsupported source format: %s", manifest.Source) + } + appsDir := filepath.Dir(sourceDir) + + plan, err := ComputeUpgradePlan(manifest.Version, appName, appsDir) + if err != nil { + return fmt.Errorf("failed to compute upgrade plan: %w", err) + } + + if plan.Blocked { + return fmt.Errorf("upgrade blocked: %s", plan.Notes) + } + + if len(plan.Steps) == 0 { + broadcaster.Publish(opID, []byte("Already up to date")) + return nil + } + + if plan.BackupRequired { + return fmt.Errorf("backup required before upgrading %s (%s -> %s) — run 'wild app backup %s' first, then retry", + appName, manifest.Version, plan.Steps[len(plan.Steps)-1].ToVersion, appName) + } + + totalSteps := len(plan.Steps) + for i, step := range plan.Steps { + broadcaster.Publish(opID, []byte(fmt.Sprintf("Step %d/%d: upgrading %s to %s\n", i+1, totalSteps, appName, step.ToVersion))) + + // Apply config migrations if present + if step.Manifest != nil && step.Manifest.Upgrade != nil && len(step.Manifest.Upgrade.ConfigMigrations) > 0 { + if err := m.applyConfigMigrations(instanceName, appName, step.Manifest.Upgrade.ConfigMigrations); err != nil { + return fmt.Errorf("step %d/%d config migration failed: %w", i+1, totalSteps, err) + } + } + + // Run pre-migration jobs + if step.Manifest != nil && step.Manifest.Upgrade != nil && step.Manifest.Upgrade.Migrations != nil { + if len(step.Manifest.Upgrade.Migrations.Pre) > 0 { + broadcaster.Publish(opID, []byte(fmt.Sprintf("Step %d/%d: running pre-migration jobs\n", i+1, totalSteps))) + if err := m.runMigrationJobs(instanceName, appName, step.Manifest.Upgrade.Migrations.Pre, step.SourceDir); err != nil { + return fmt.Errorf("step %d/%d pre-migration failed: %w", i+1, totalSteps, err) + } + } + } + + // Update from source (preserveSource stays the same across all steps) + if err := m.updateFromSource(instanceName, appName, step.SourceDir, manifest.Source); err != nil { + return fmt.Errorf("step %d/%d update failed: %w", i+1, totalSteps, err) + } + + // Deploy to cluster + broadcaster.Publish(opID, []byte(fmt.Sprintf("Step %d/%d: deploying %s\n", i+1, totalSteps, step.ToVersion))) + if err := m.Deploy(instanceName, appName, opID, broadcaster); err != nil { + return fmt.Errorf("step %d/%d deploy failed: %w", i+1, totalSteps, err) + } + + // Run post-migration jobs + if step.Manifest != nil && step.Manifest.Upgrade != nil && step.Manifest.Upgrade.Migrations != nil { + if len(step.Manifest.Upgrade.Migrations.Post) > 0 { + broadcaster.Publish(opID, []byte(fmt.Sprintf("Step %d/%d: running post-migration jobs\n", i+1, totalSteps))) + if err := m.runMigrationJobs(instanceName, appName, step.Manifest.Upgrade.Migrations.Post, step.SourceDir); err != nil { + return fmt.Errorf("step %d/%d post-migration failed: %w", i+1, totalSteps, err) + } + } + } + } + + broadcaster.Publish(opID, []byte(fmt.Sprintf("Upgrade complete: %s is now at %s\n", appName, plan.Steps[totalSteps-1].ToVersion))) + return nil +} + +// GetUpgradePlan reads the installed version and computes the upgrade plan from the Wild Directory +func (m *Manager) GetUpgradePlan(instanceName, appName string) (*UpgradePlan, error) { + instancePath := tools.GetInstancePath(m.dataDir, instanceName) + manifestPath := filepath.Join(instancePath, "apps", appName, "manifest.yaml") + + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest: %w", err) + } + + var manifest AppManifest + if err := yaml.Unmarshal(manifestData, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest: %w", err) + } + + if manifest.Source == "" { + return &UpgradePlan{}, nil // No source, nothing to upgrade + } + + sourceDir := parseSourceDir(manifest.Source) + if sourceDir == "" { + return nil, fmt.Errorf("unsupported source format: %s", manifest.Source) + } + + return ComputeUpgradePlan(manifest.Version, appName, filepath.Dir(sourceDir)) +} + +// applyConfigMigrations renames config keys in config.yaml for an app +func (m *Manager) applyConfigMigrations(instanceName, appName string, migrations map[string]string) error { + configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName) + yq := tools.NewYQ() + + configLock := configFile + ".lock" + return storage.WithLock(configLock, func() error { + for oldKey, newKey := range migrations { + oldPath := fmt.Sprintf(".apps.%s.%s", appName, oldKey) + newPath := fmt.Sprintf(".apps.%s.%s", appName, newKey) + + value, err := yq.Get(configFile, oldPath) + if err != nil || value == "" || value == "null" { + continue // Key doesn't exist, skip + } + + if err := yq.Set(configFile, newPath, value); err != nil { + return fmt.Errorf("failed to set %s: %w", newKey, err) + } + if err := yq.Delete(configFile, oldPath); err != nil { + return fmt.Errorf("failed to delete %s: %w", oldKey, err) + } + } + return nil + }) +} + +// runMigrationJobs applies K8s Job manifests, waits for completion, then cleans up +func (m *Manager) runMigrationJobs(instanceName, appName string, jobPaths []string, sourceDir string) error { + kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName) + namespace := m.ResolveNamespace(instanceName, appName) + + for _, jobPath := range jobPaths { + jobFile := filepath.Join(sourceDir, jobPath) + if !storage.FileExists(jobFile) { + return fmt.Errorf("migration job not found: %s", jobPath) + } + + // Apply the job + cmd := exec.Command("kubectl", "apply", "-f", jobFile, "-n", namespace) + tools.WithKubeconfig(cmd, kubeconfigPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to apply migration job %s: %s: %w", jobPath, string(output), err) + } + + // Wait for job completion + cmd = exec.Command("kubectl", "wait", "--for=condition=complete", "job", "--all", + "-n", namespace, "--timeout=300s") + tools.WithKubeconfig(cmd, kubeconfigPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("migration job %s did not complete: %s: %w", jobPath, string(output), err) + } + + // Clean up the job + cmd = exec.Command("kubectl", "delete", "-f", jobFile, "-n", namespace, "--ignore-not-found") + tools.WithKubeconfig(cmd, kubeconfigPath) + cmd.CombinedOutput() // Best effort cleanup + } + + return nil +} + // Eject converts an app from package-managed to custom func (m *Manager) Eject(instanceName, appName string) error { instancePath := tools.GetInstancePath(m.dataDir, instanceName) @@ -1776,7 +1985,7 @@ func (m *Manager) compileFromPackage(appName, appDestDir, packageDir, configFile } for _, entry := range packageEntries { - if entry.Name() == "manifest.yaml" { + if entry.Name() == "manifest.yaml" || strings.HasPrefix(entry.Name(), ".") { continue } @@ -1887,6 +2096,9 @@ func (m *Manager) Fetch(instanceName, appName string) error { } for _, entry := range entries { + if entry.Name() == ".versions" { + continue + } sourcePath := filepath.Join(sourceDir, entry.Name()) packagePath := filepath.Join(packageDir, entry.Name()) @@ -1908,7 +2120,7 @@ func (m *Manager) Fetch(instanceName, appName string) error { // Update the compiled manifest with metadata from the source manifest // Preserves instance-specific fields (source, installedAs) while updating - // package metadata (version, scripts, deploy, description, icon) + // package metadata (version, scripts, deploy, description, icon, upgrade) sourceManifestPath := filepath.Join(packageDir, "manifest.yaml") if sourceData, err := os.ReadFile(sourceManifestPath); err == nil { var sourceManifest AppManifest @@ -1918,6 +2130,7 @@ func (m *Manager) Fetch(instanceName, appName string) error { manifest.Icon = sourceManifest.Icon manifest.Scripts = sourceManifest.Scripts manifest.Deploy = sourceManifest.Deploy + manifest.Upgrade = sourceManifest.Upgrade manifestYAML, err := yaml.Marshal(manifest) if err == nil { storage.WriteFile(manifestPath, manifestYAML, 0644) @@ -1936,7 +2149,7 @@ func (m *Manager) Install(instanceName, appName string, fetch, deploy bool, conf if fetch || !storage.FileExists(appDestDir) { // Add (which includes fetch + compile) - if err := m.Add(instanceName, appName, config, requiredAppMappings); err != nil { + if err := m.Add(instanceName, appName, "", config, requiredAppMappings); err != nil { return fmt.Errorf("add failed: %w", err) } } else { diff --git a/api/internal/apps/apps_test.go b/api/internal/apps/apps_test.go index 35adacd..daf7f1e 100644 --- a/api/internal/apps/apps_test.go +++ b/api/internal/apps/apps_test.go @@ -266,7 +266,7 @@ namespace: loomio // Create app manager and add the app manager := NewManager(dataDir, appsDir) - err = manager.Add(instanceName, appName, nil, nil) + err = manager.Add(instanceName, appName, "", nil, nil) if err != nil { t.Fatalf("Failed to add app: %v", err) } @@ -555,7 +555,7 @@ namespace: testapp // Create app manager and add the app manager := NewManager(dataDir, appsDir) - err = manager.Add(instanceName, appName, nil, nil) + err = manager.Add(instanceName, appName, "", nil, nil) if err != nil { t.Fatalf("Failed to add app: %v", err) } @@ -676,7 +676,7 @@ func TestSecretTemplateWithMultipleRandoms(t *testing.T) { // Create app manager and add the app manager := NewManager(dataDir, appsDir) - err = manager.Add(instanceName, appName, nil, nil) + err = manager.Add(instanceName, appName, "", nil, nil) if err != nil { t.Fatalf("Failed to add app: %v", err) } @@ -824,7 +824,7 @@ resources: // Create app manager and add the app manager := NewManager(dataDir, appsDir) - err = manager.Add(instanceName, appName, nil, nil) + err = manager.Add(instanceName, appName, "", nil, nil) if err != nil { t.Fatalf("Failed to add app: %v", err) } @@ -984,7 +984,7 @@ func TestExistingSecretsNotOverwritten(t *testing.T) { // Create app manager and add the app manager := NewManager(dataDir, appsDir) - err = manager.Add(instanceName, appName, nil, nil) + err = manager.Add(instanceName, appName, "", nil, nil) if err != nil { t.Fatalf("Failed to add app: %v", err) } @@ -1080,7 +1080,7 @@ defaultSecrets: // Add the app mgr := NewManager(dataDir, appsDir) - err = mgr.Add(instanceName, appName, nil, nil) + err = mgr.Add(instanceName, appName, "", nil, nil) if err != nil { t.Fatalf("Failed to add app: %v", err) } @@ -1239,7 +1239,7 @@ defaultConfig: // Add the app initially mgr := NewManager(dataDir, appsDir) - err = mgr.Add(instanceName, appName, nil, nil) + err = mgr.Add(instanceName, appName, "", nil, nil) if err != nil { t.Fatalf("Failed to add app: %v", err) } @@ -1662,7 +1662,7 @@ defaultConfig: // Add the app mgr := NewManager(dataDir, appsDir) - err = mgr.Add(instanceName, appName, nil, nil) + err = mgr.Add(instanceName, appName, "", nil, nil) if err != nil { t.Fatalf("Failed to add app: %v", err) } diff --git a/api/internal/apps/drift.go b/api/internal/apps/drift.go index 1789c1e..faef247 100644 --- a/api/internal/apps/drift.go +++ b/api/internal/apps/drift.go @@ -28,7 +28,7 @@ func (m *Manager) computeDrift(instanceName, appName, appDir, kubeconfigPath, st // Source and compilation drift only apply to source-managed apps if manifest.Source != "" { - drift.Source = m.checkSourceDrift(manifest, packageDir) + drift.Source = m.checkSourceDrift(manifest, packageDir, appName) if drift.Source != nil { hasAnyDrift = true } @@ -57,7 +57,7 @@ func (m *Manager) computeDrift(instanceName, appName, appDir, kubeconfigPath, st } // checkSourceDrift compares the installed app against the Wild Directory source. -func (m *Manager) checkSourceDrift(manifest *AppManifest, packageDir string) *StageDrift { +func (m *Manager) checkSourceDrift(manifest *AppManifest, packageDir, appName string) *StageDrift { if manifest.Source == "" { return nil } @@ -92,12 +92,22 @@ func (m *Manager) checkSourceDrift(manifest *AppManifest, packageDir string) *St } if manifest.Version != sourceManifest.Version { - return &StageDrift{ + sd := &StageDrift{ Drifted: true, Reason: fmt.Sprintf("update available: %s → %s", manifest.Version, sourceManifest.Version), CurrentVersion: manifest.Version, AvailableVersion: sourceManifest.Version, } + + // Enrich with upgrade plan info + appsDir := filepath.Dir(sourceDir) + if plan, err := ComputeUpgradePlan(manifest.Version, appName, appsDir); err == nil && plan != nil { + sd.UpgradeBlocked = plan.Blocked + sd.UpgradeNotes = plan.Notes + sd.UpgradeSteps = len(plan.Steps) + } + + return sd } return nil diff --git a/api/internal/apps/drift_test.go b/api/internal/apps/drift_test.go index 80bfc3e..c798e34 100644 --- a/api/internal/apps/drift_test.go +++ b/api/internal/apps/drift_test.go @@ -142,7 +142,7 @@ func TestCheckSourceDrift_NoDrift(t *testing.T) { } m := &Manager{} - result := m.checkSourceDrift(manifest, packageDir) + result := m.checkSourceDrift(manifest, packageDir, "myapp") if result != nil { t.Errorf("expected no drift, got %+v", result) } @@ -173,7 +173,7 @@ func TestCheckSourceDrift_VersionDrift(t *testing.T) { } m := &Manager{} - result := m.checkSourceDrift(manifest, packageDir) + result := m.checkSourceDrift(manifest, packageDir, "myapp") if result == nil { t.Fatal("expected drift, got nil") } @@ -195,7 +195,7 @@ func TestCheckSourceDrift_NoSource(t *testing.T) { } m := &Manager{} - result := m.checkSourceDrift(manifest, "/nonexistent") + result := m.checkSourceDrift(manifest, "/nonexistent", "myapp") if result != nil { t.Errorf("expected nil for ejected app, got %+v", result) } @@ -220,7 +220,7 @@ func TestCheckSourceDrift_PackageMissing(t *testing.T) { packageDir := filepath.Join(tmpDir, "nonexistent-package") m := &Manager{} - result := m.checkSourceDrift(manifest, packageDir) + result := m.checkSourceDrift(manifest, packageDir, "myapp") if result == nil { t.Fatal("expected drift for missing package dir, got nil") } @@ -239,7 +239,7 @@ func TestCheckSourceDrift_SourceDirMissing(t *testing.T) { packageDir := "/tmp" // exists but irrelevant m := &Manager{} - result := m.checkSourceDrift(manifest, packageDir) + result := m.checkSourceDrift(manifest, packageDir, "myapp") if result != nil { t.Errorf("expected nil when source dir is missing, got %+v", result) } diff --git a/api/internal/apps/models.go b/api/internal/apps/models.go index 7fb692c..1fad3e6 100644 --- a/api/internal/apps/models.go +++ b/api/internal/apps/models.go @@ -31,6 +31,7 @@ type AppManifest struct { Source string `json:"source,omitempty" yaml:"source,omitempty"` Scripts []Script `json:"scripts,omitempty" yaml:"scripts,omitempty"` Deploy *DeployConfig `json:"deploy,omitempty" yaml:"deploy,omitempty"` + Upgrade *UpgradeConfig `json:"upgrade,omitempty" yaml:"upgrade,omitempty"` } // DeployConfig declares deployment behavior in the manifest, replacing install.sh scripts @@ -114,6 +115,37 @@ type StageDrift struct { Reason string `json:"reason,omitempty"` CurrentVersion string `json:"currentVersion,omitempty"` AvailableVersion string `json:"availableVersion,omitempty"` + UpgradeBlocked bool `json:"upgradeBlocked,omitempty"` + UpgradeNotes string `json:"upgradeNotes,omitempty"` + UpgradeSteps int `json:"upgradeSteps,omitempty"` +} + +// UpgradeConfig declares upgrade behavior and constraints in the manifest +type UpgradeConfig struct { + From []UpgradeFromRule `json:"from,omitempty" yaml:"from,omitempty"` + PreUpgrade *PreUpgradeConfig `json:"preUpgrade,omitempty" yaml:"preUpgrade,omitempty"` + Migrations *MigrationConfig `json:"migrations,omitempty" yaml:"migrations,omitempty"` + ConfigMigrations map[string]string `json:"configMigrations,omitempty" yaml:"configMigrations,omitempty"` +} + +// UpgradeFromRule defines a version constraint and optional upgrade path +type UpgradeFromRule struct { + Version string `json:"version" yaml:"version"` // e.g. ">=1.23.0", "<1.21.0", ">0" + Via string `json:"via,omitempty" yaml:"via,omitempty"` // waypoint version in .versions/ + Blocked bool `json:"blocked,omitempty" yaml:"blocked,omitempty"` + Notes string `json:"notes,omitempty" yaml:"notes,omitempty"` +} + +// PreUpgradeConfig defines pre-upgrade requirements +type PreUpgradeConfig struct { + Backup string `json:"backup,omitempty" yaml:"backup,omitempty"` // "none", "recommended", "required" + Notes string `json:"notes,omitempty" yaml:"notes,omitempty"` +} + +// MigrationConfig defines pre/post-deploy migration jobs for a version transition +type MigrationConfig struct { + Pre []string `json:"pre,omitempty" yaml:"pre,omitempty"` // paths to K8s Job YAMLs relative to app dir + Post []string `json:"post,omitempty" yaml:"post,omitempty"` } // RuntimeStatus contains runtime information from kubernetes diff --git a/api/internal/apps/upgrade.go b/api/internal/apps/upgrade.go new file mode 100644 index 0000000..b35c752 --- /dev/null +++ b/api/internal/apps/upgrade.go @@ -0,0 +1,261 @@ +package apps + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// UpgradeStep represents one step in a computed upgrade chain +type UpgradeStep struct { + FromVersion string `json:"fromVersion"` + ToVersion string `json:"toVersion"` + SourceDir string `json:"-"` + Manifest *AppManifest `json:"-"` +} + +// UpgradePlan is the computed plan for upgrading an app +type UpgradePlan struct { + Steps []UpgradeStep `json:"steps"` + Blocked bool `json:"blocked"` + Notes string `json:"notes,omitempty"` + BackupRequired bool `json:"backupRequired,omitempty"` + BackupRecommended bool `json:"backupRecommended,omitempty"` +} + +// ParseAppVersion extracts major, minor, patch, and packaging revision from a version string. +// Handles formats like "1.24.3-1", "v3.4", "5.118.1", "v4.0.18-2". +func ParseAppVersion(v string) (major, minor, patch, revision int) { + v = strings.TrimPrefix(v, "v") + + // Split upstream from revision + upstream := v + if idx := strings.LastIndexByte(v, '-'); idx >= 0 { + revStr := v[idx+1:] + if r, err := strconv.Atoi(revStr); err == nil { + revision = r + upstream = v[:idx] + } + } + + fmt.Sscanf(upstream, "%d.%d.%d", &major, &minor, &patch) + return +} + +// CompareAppVersions compares two version strings including packaging revision. +// Returns >0 if a > b, <0 if a < b, 0 if equal. +func CompareAppVersions(a, b string) int { + aMaj, aMin, aPat, aRev := ParseAppVersion(a) + bMaj, bMin, bPat, bRev := ParseAppVersion(b) + + if aMaj != bMaj { + return aMaj - bMaj + } + if aMin != bMin { + return aMin - bMin + } + if aPat != bPat { + return aPat - bPat + } + return aRev - bRev +} + +// MatchVersionConstraint checks whether version satisfies a constraint like ">=1.23.0", "<1.21.0", ">0". +// The constraint's revision suffix is ignored so ">=1.23.0" matches "1.23.0-1". +func MatchVersionConstraint(constraint, version string) bool { + constraint = strings.TrimSpace(constraint) + if constraint == "" { + return false + } + + var op string + var target string + + // Parse operator + switch { + case strings.HasPrefix(constraint, ">="): + op, target = ">=", strings.TrimSpace(constraint[2:]) + case strings.HasPrefix(constraint, "<="): + op, target = "<=", strings.TrimSpace(constraint[2:]) + case strings.HasPrefix(constraint, ">"): + op, target = ">", strings.TrimSpace(constraint[1:]) + case strings.HasPrefix(constraint, "<"): + op, target = "<", strings.TrimSpace(constraint[1:]) + case strings.HasPrefix(constraint, "="): + op, target = "=", strings.TrimSpace(constraint[1:]) + default: + // Bare version = exact match + op, target = "=", constraint + } + + // Special case: ">0" matches any version + if op == ">" && target == "0" { + return true + } + + // Compare upstream portions only (ignore revision in constraint target) + tMaj, tMin, tPat, _ := ParseAppVersion(target) + vMaj, vMin, vPat, _ := ParseAppVersion(version) + + cmp := 0 + if vMaj != tMaj { + cmp = vMaj - tMaj + } else if vMin != tMin { + cmp = vMin - tMin + } else { + cmp = vPat - tPat + } + + switch op { + case ">=": + return cmp >= 0 + case ">": + return cmp > 0 + case "<=": + return cmp <= 0 + case "<": + return cmp < 0 + case "=": + return cmp == 0 + } + return false +} + +// ComputeUpgradePlan computes the upgrade path from installedVersion to the latest version +// in the Wild Directory. Returns an empty plan (0 steps) if already current. +func ComputeUpgradePlan(installedVersion, appName, appsDir string) (*UpgradePlan, error) { + latestDir := filepath.Join(appsDir, appName) + return computeUpgradePlanRecursive(installedVersion, appName, appsDir, latestDir, make(map[string]bool), 0) +} + +func computeUpgradePlanRecursive(installedVersion, appName, appsDir, targetDir string, visited map[string]bool, depth int) (*UpgradePlan, error) { + if depth > 10 { + return &UpgradePlan{Blocked: true, Notes: "upgrade path exceeds maximum depth"}, nil + } + + // Read target manifest + manifestPath := filepath.Join(targetDir, "manifest.yaml") + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest at %s: %w", manifestPath, err) + } + + var manifest AppManifest + if err := yaml.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest at %s: %w", manifestPath, err) + } + + // Already current + if installedVersion == manifest.Version { + return &UpgradePlan{}, nil + } + + // Cycle detection + if visited[manifest.Version] { + return &UpgradePlan{Blocked: true, Notes: fmt.Sprintf("circular upgrade path at version %s", manifest.Version)}, nil + } + visited[manifest.Version] = true + + // No upgrade block = any version can upgrade directly (backward compat) + if manifest.Upgrade == nil || len(manifest.Upgrade.From) == 0 { + plan := &UpgradePlan{ + Steps: []UpgradeStep{{ + FromVersion: installedVersion, + ToVersion: manifest.Version, + SourceDir: targetDir, + Manifest: &manifest, + }}, + } + setBackupFlags(plan, &manifest) + return plan, nil + } + + // Find matching rule + for _, rule := range manifest.Upgrade.From { + if !MatchVersionConstraint(rule.Version, installedVersion) { + continue + } + + if rule.Blocked { + return &UpgradePlan{Blocked: true, Notes: rule.Notes}, nil + } + + if rule.Via != "" { + // Load waypoint and compute path to it, then from it to target + waypointDir := filepath.Join(appsDir, appName, ".versions", rule.Via) + waypointManifestPath := filepath.Join(waypointDir, "manifest.yaml") + if _, err := os.Stat(waypointManifestPath); os.IsNotExist(err) { + return nil, fmt.Errorf("waypoint version %s not found at %s", rule.Via, waypointDir) + } + + // Compute path from installed -> waypoint + toWaypoint, err := computeUpgradePlanRecursive(installedVersion, appName, appsDir, waypointDir, visited, depth+1) + if err != nil { + return nil, err + } + if toWaypoint.Blocked { + return toWaypoint, nil + } + + // Read waypoint manifest version for the next hop + wpData, err := os.ReadFile(waypointManifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read waypoint manifest: %w", err) + } + var wpManifest AppManifest + if err := yaml.Unmarshal(wpData, &wpManifest); err != nil { + return nil, fmt.Errorf("failed to parse waypoint manifest: %w", err) + } + + // Add the step from waypoint -> target + plan := &UpgradePlan{ + Steps: append(toWaypoint.Steps, UpgradeStep{ + FromVersion: wpManifest.Version, + ToVersion: manifest.Version, + SourceDir: targetDir, + Manifest: &manifest, + }), + } + + // Aggregate backup flags from all steps + for _, step := range plan.Steps { + setBackupFlags(plan, step.Manifest) + } + return plan, nil + } + + // Direct upgrade + plan := &UpgradePlan{ + Steps: []UpgradeStep{{ + FromVersion: installedVersion, + ToVersion: manifest.Version, + SourceDir: targetDir, + Manifest: &manifest, + }}, + } + setBackupFlags(plan, &manifest) + return plan, nil + } + + // No matching rule + return &UpgradePlan{ + Blocked: true, + Notes: fmt.Sprintf("no upgrade path from version %s to %s", installedVersion, manifest.Version), + }, nil +} + +func setBackupFlags(plan *UpgradePlan, manifest *AppManifest) { + if manifest.Upgrade == nil || manifest.Upgrade.PreUpgrade == nil { + return + } + switch manifest.Upgrade.PreUpgrade.Backup { + case "required": + plan.BackupRequired = true + case "recommended": + plan.BackupRecommended = true + } +} diff --git a/api/internal/apps/upgrade_test.go b/api/internal/apps/upgrade_test.go new file mode 100644 index 0000000..ede759f --- /dev/null +++ b/api/internal/apps/upgrade_test.go @@ -0,0 +1,602 @@ +package apps + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestParseAppVersion(t *testing.T) { + tests := []struct { + input string + major, minor, patch, revision int + }{ + {"1.24.3-1", 1, 24, 3, 1}, + {"v3.4", 3, 4, 0, 0}, + {"5.118.1", 5, 118, 1, 0}, + {"v4.0.18-2", 4, 0, 18, 2}, + {"1.0.0", 1, 0, 0, 0}, + {"0.1.0-10", 0, 1, 0, 10}, + {"v1.0.0-1", 1, 0, 0, 1}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + maj, min, pat, rev := ParseAppVersion(tt.input) + if maj != tt.major || min != tt.minor || pat != tt.patch || rev != tt.revision { + t.Errorf("ParseAppVersion(%q) = (%d,%d,%d,%d), want (%d,%d,%d,%d)", + tt.input, maj, min, pat, rev, tt.major, tt.minor, tt.patch, tt.revision) + } + }) + } +} + +func TestCompareAppVersions(t *testing.T) { + tests := []struct { + name string + a, b string + want int // >0, <0, or 0 + }{ + {"equal", "1.0.0", "1.0.0", 0}, + {"major greater", "2.0.0", "1.0.0", 1}, + {"major less", "1.0.0", "2.0.0", -1}, + {"minor greater", "1.2.0", "1.1.0", 1}, + {"minor less", "1.1.0", "1.2.0", -1}, + {"patch greater", "1.0.2", "1.0.1", 1}, + {"patch less", "1.0.1", "1.0.2", -1}, + {"revision tiebreaker", "1.0.0-2", "1.0.0-1", 1}, + {"revision less", "1.0.0-1", "1.0.0-2", -1}, + {"revision equal", "1.0.0-1", "1.0.0-1", 0}, + {"no revision vs revision", "1.0.0", "1.0.0-1", -1}, + {"v prefix ignored", "v1.0.0", "1.0.0", 0}, + {"real versions", "1.24.3-1", "1.23.0", 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CompareAppVersions(tt.a, tt.b) + if (tt.want > 0 && got <= 0) || (tt.want < 0 && got >= 0) || (tt.want == 0 && got != 0) { + t.Errorf("CompareAppVersions(%q, %q) = %d, want sign %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestMatchVersionConstraint(t *testing.T) { + tests := []struct { + name string + constraint string + version string + want bool + }{ + {"gte match", ">=1.23.0", "1.24.0", true}, + {"gte exact", ">=1.23.0", "1.23.0", true}, + {"gte with revision", ">=1.23.0", "1.23.0-1", true}, + {"gte below", ">=1.23.0", "1.22.0", false}, + {"gt match", ">1.0.0", "1.0.1", true}, + {"gt exact", ">1.0.0", "1.0.0", false}, + {"lt match", "<2.0.0", "1.9.9", true}, + {"lt exact", "<2.0.0", "2.0.0", false}, + {"lt above", "<2.0.0", "2.0.1", false}, + {"lte match", "<=2.0.0", "2.0.0", true}, + {"lte below", "<=2.0.0", "1.9.0", true}, + {"lte above", "<=2.0.0", "2.0.1", false}, + {"exact match", "=1.5.0", "1.5.0", true}, + {"exact no match", "=1.5.0", "1.5.1", false}, + {"bare version exact", "1.5.0", "1.5.0", true}, + {"bare version no match", "1.5.0", "1.5.1", false}, + {"gt zero universal", ">0", "0.0.1", true}, + {"gt zero any version", ">0", "99.99.99", true}, + {"empty constraint", "", "1.0.0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchVersionConstraint(tt.constraint, tt.version) + if got != tt.want { + t.Errorf("MatchVersionConstraint(%q, %q) = %v, want %v", + tt.constraint, tt.version, got, tt.want) + } + }) + } +} + +// writeManifest is a test helper that writes an AppManifest as YAML to a manifest.yaml file. +func writeManifest(t *testing.T, dir string, manifest AppManifest) { + t.Helper() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + data, err := yaml.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "manifest.yaml"), data, 0644); err != nil { + t.Fatal(err) + } +} + +func TestComputeUpgradePlan_AlreadyCurrent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "1.0.0", + }) + + plan, err := ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if len(plan.Steps) != 0 { + t.Errorf("expected 0 steps for current version, got %d", len(plan.Steps)) + } + if plan.Blocked { + t.Error("expected not blocked") + } +} + +func TestComputeUpgradePlan_NoUpgradeBlock(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // App with no upgrade block = any version can upgrade directly (backward compat) + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + }) + + plan, err := ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if plan.Blocked { + t.Error("expected not blocked") + } + if len(plan.Steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(plan.Steps)) + } + if plan.Steps[0].FromVersion != "1.0.0" || plan.Steps[0].ToVersion != "2.0.0" { + t.Errorf("step = %s -> %s, want 1.0.0 -> 2.0.0", + plan.Steps[0].FromVersion, plan.Steps[0].ToVersion) + } +} + +func TestComputeUpgradePlan_DirectUpgrade(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">=1.0.0"}, + }, + }, + }) + + plan, err := ComputeUpgradePlan("1.5.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if plan.Blocked { + t.Error("expected not blocked") + } + if len(plan.Steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(plan.Steps)) + } + if plan.Steps[0].FromVersion != "1.5.0" || plan.Steps[0].ToVersion != "2.0.0" { + t.Errorf("step = %s -> %s, want 1.5.0 -> 2.0.0", + plan.Steps[0].FromVersion, plan.Steps[0].ToVersion) + } +} + +func TestComputeUpgradePlan_Blocked(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "3.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: "<1.0.0", Blocked: true, Notes: "too old, manual migration required"}, + {Version: ">=1.0.0"}, + }, + }, + }) + + plan, err := ComputeUpgradePlan("0.5.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if !plan.Blocked { + t.Error("expected blocked") + } + if plan.Notes != "too old, manual migration required" { + t.Errorf("unexpected notes: %q", plan.Notes) + } +} + +func TestComputeUpgradePlan_NoMatchingRule(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "3.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">=2.0.0"}, // only allows from 2.x+ + }, + }, + }) + + plan, err := ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if !plan.Blocked { + t.Error("expected blocked when no rule matches") + } + if plan.Notes == "" { + t.Error("expected notes explaining no upgrade path") + } +} + +func TestComputeUpgradePlan_ViaWaypoint(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Latest version: 3.0.0, requires going through 2.0.0 for versions <2.0.0 + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "3.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">=2.0.0"}, // direct from 2.x + {Version: ">=1.0.0", Via: "2.0.0"}, + }, + }, + }) + + // Waypoint at 2.0.0: accepts any version directly + waypointDir := filepath.Join(appDir, ".versions", "2.0.0") + writeManifest(t, waypointDir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + }) + + plan, err := ComputeUpgradePlan("1.5.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if plan.Blocked { + t.Errorf("expected not blocked, notes: %s", plan.Notes) + } + if len(plan.Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(plan.Steps)) + } + + // Step 1: 1.5.0 -> 2.0.0 + if plan.Steps[0].FromVersion != "1.5.0" || plan.Steps[0].ToVersion != "2.0.0" { + t.Errorf("step 1 = %s -> %s, want 1.5.0 -> 2.0.0", + plan.Steps[0].FromVersion, plan.Steps[0].ToVersion) + } + // Step 2: 2.0.0 -> 3.0.0 + if plan.Steps[1].FromVersion != "2.0.0" || plan.Steps[1].ToVersion != "3.0.0" { + t.Errorf("step 2 = %s -> %s, want 2.0.0 -> 3.0.0", + plan.Steps[1].FromVersion, plan.Steps[1].ToVersion) + } +} + +func TestComputeUpgradePlan_MultipleWaypoints(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Latest: 4.0.0, requires 3.0.0 for <3.0.0 + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "4.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">=3.0.0"}, + {Version: ">=1.0.0", Via: "3.0.0"}, + }, + }, + }) + + // Waypoint 3.0.0: requires 2.0.0 for <2.0.0 + wp3Dir := filepath.Join(appDir, ".versions", "3.0.0") + writeManifest(t, wp3Dir, AppManifest{ + Name: "myapp", + Version: "3.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">=2.0.0"}, + {Version: ">=1.0.0", Via: "2.0.0"}, + }, + }, + }) + + // Waypoint 2.0.0: accepts anything + wp2Dir := filepath.Join(appDir, ".versions", "2.0.0") + writeManifest(t, wp2Dir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + }) + + plan, err := ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if plan.Blocked { + t.Errorf("expected not blocked, notes: %s", plan.Notes) + } + if len(plan.Steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(plan.Steps)) + } + + // Step 1: 1.0.0 -> 2.0.0 + if plan.Steps[0].FromVersion != "1.0.0" || plan.Steps[0].ToVersion != "2.0.0" { + t.Errorf("step 1 = %s -> %s, want 1.0.0 -> 2.0.0", + plan.Steps[0].FromVersion, plan.Steps[0].ToVersion) + } + // Step 2: 2.0.0 -> 3.0.0 + if plan.Steps[1].FromVersion != "2.0.0" || plan.Steps[1].ToVersion != "3.0.0" { + t.Errorf("step 2 = %s -> %s, want 2.0.0 -> 3.0.0", + plan.Steps[1].FromVersion, plan.Steps[1].ToVersion) + } + // Step 3: 3.0.0 -> 4.0.0 + if plan.Steps[2].FromVersion != "3.0.0" || plan.Steps[2].ToVersion != "4.0.0" { + t.Errorf("step 3 = %s -> %s, want 3.0.0 -> 4.0.0", + plan.Steps[2].FromVersion, plan.Steps[2].ToVersion) + } +} + +func TestComputeUpgradePlan_CycleDetection(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Latest: 2.0.0, via waypoint 1.5.0 + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">0", Via: "1.5.0"}, + }, + }, + }) + + // Waypoint 1.5.0 points back to 2.0.0 via itself (cycle) + wpDir := filepath.Join(appDir, ".versions", "1.5.0") + writeManifest(t, wpDir, AppManifest{ + Name: "myapp", + Version: "1.5.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">0", Via: "1.5.0"}, // self-reference + }, + }, + }) + + plan, err := ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if !plan.Blocked { + t.Error("expected blocked for circular upgrade path") + } +} + +func TestComputeUpgradePlan_BackupRequired(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">0"}, + }, + PreUpgrade: &PreUpgradeConfig{ + Backup: "required", + }, + }, + }) + + plan, err := ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if plan.Blocked { + t.Error("expected not blocked") + } + if !plan.BackupRequired { + t.Error("expected BackupRequired to be true") + } +} + +func TestComputeUpgradePlan_BackupRecommended(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">0"}, + }, + PreUpgrade: &PreUpgradeConfig{ + Backup: "recommended", + }, + }, + }) + + plan, err := ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if !plan.BackupRecommended { + t.Error("expected BackupRecommended to be true") + } + if plan.BackupRequired { + t.Error("expected BackupRequired to be false") + } +} + +func TestComputeUpgradePlan_WaypointMissing(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">0", Via: "1.5.0"}, // waypoint doesn't exist + }, + }, + }) + + _, err = ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err == nil { + t.Error("expected error for missing waypoint") + } +} + +func TestComputeUpgradePlan_RuleOrdering(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // First matching rule wins + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "3.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">=2.0.0"}, // direct for 2.x+ + {Version: ">=1.0.0", Blocked: true, Notes: "must be on 2.x+"}, // block for 1.x + }, + }, + }) + + // Version 2.5.0 matches first rule (direct) + plan, err := ComputeUpgradePlan("2.5.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if plan.Blocked { + t.Error("expected 2.5.0 to not be blocked") + } + + // Version 1.5.0 matches second rule (blocked) + plan, err = ComputeUpgradePlan("1.5.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if !plan.Blocked { + t.Error("expected 1.5.0 to be blocked") + } +} + +func TestComputeUpgradePlan_BackupAggregation(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "upgrade-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Latest version has no backup requirement, but waypoint does + appDir := filepath.Join(tmpDir, "myapp") + writeManifest(t, appDir, AppManifest{ + Name: "myapp", + Version: "3.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">=2.0.0"}, + {Version: ">=1.0.0", Via: "2.0.0"}, + }, + }, + }) + + waypointDir := filepath.Join(appDir, ".versions", "2.0.0") + writeManifest(t, waypointDir, AppManifest{ + Name: "myapp", + Version: "2.0.0", + Upgrade: &UpgradeConfig{ + From: []UpgradeFromRule{ + {Version: ">0"}, + }, + PreUpgrade: &PreUpgradeConfig{ + Backup: "required", + }, + }, + }) + + plan, err := ComputeUpgradePlan("1.0.0", "myapp", tmpDir) + if err != nil { + t.Fatal(err) + } + if plan.Blocked { + t.Errorf("expected not blocked, notes: %s", plan.Notes) + } + if !plan.BackupRequired { + t.Error("expected BackupRequired to be true (aggregated from waypoint)") + } +} diff --git a/api/test/e2e/tests/08-upgrade.sh b/api/test/e2e/tests/08-upgrade.sh new file mode 100644 index 0000000..2a70f1f --- /dev/null +++ b/api/test/e2e/tests/08-upgrade.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Test: Versioned upgrade system — plan computation, multi-step execution, blocked paths +# This test manages its own app lifecycle (delete → add at old version → upgrade → cleanup) + +MANIFEST_PATH="${DATA_DIR}/instances/${INSTANCE}/apps/${APP_NAME}/manifest.yaml" + +# --- Setup: ensure clean slate, then add at waypoint version --- + +echo " Setting up: removing existing ${APP_NAME} if present..." +api_get "/api/v1/instances/${INSTANCE}/apps/${APP_NAME}/status" +if [[ "$HTTP_CODE" == "200" ]]; then + start_async_delete_and_wait "/api/v1/instances/${INSTANCE}/apps/${APP_NAME}" "$DELETE_TIMEOUT" || true + sleep 5 +fi + +test_start "Upgrade setup: add app at waypoint version 1.0.0-1" +api_post "/api/v1/instances/${INSTANCE}/apps" "{\"name\":\"${APP_NAME}\",\"version\":\"1.0.0-1\"}" +assert_http_one_of "200 201" "Add at version 1.0.0-1 should succeed" + +test_start "Upgrade setup: installed version is 1.0.0-1" +installed_version=$(grep '^version:' "$MANIFEST_PATH" | head -1 | awk '{print $2}') +assert_eq "$installed_version" "1.0.0-1" "Should be installed at waypoint version" + +# --- Upgrade plan: multi-step (1.0.0-1 → 2.0.0) --- + +test_start "Upgrade plan: computes path from 1.0.0-1 to 2.0.0" +api_get "/api/v1/instances/${INSTANCE}/apps/${APP_NAME}/upgrade-plan" +assert_http "200" "Upgrade plan should return 200" + +test_start "Upgrade plan: has 1 step (1.0.0-1 → 2.0.0)" +steps=$(echo "$RESP" | jq -r '.steps | length' 2>/dev/null) +assert_eq "$steps" "1" "Should have 1 step from waypoint to latest" + +test_start "Upgrade plan: step targets 2.0.0" +step_to=$(echo "$RESP" | jq -r '.steps[0].toVersion' 2>/dev/null) +assert_eq "$step_to" "2.0.0" "Step should target latest version" + +test_start "Upgrade plan: backup recommended" +backup_rec=$(echo "$RESP" | jq -r '.backupRecommended' 2>/dev/null) +assert_eq "$backup_rec" "true" "Backup recommended should be true" + +test_start "Upgrade plan: not blocked" +blocked=$(echo "$RESP" | jq -r '.blocked' 2>/dev/null) +assert_eq "$blocked" "false" "Should not be blocked" + +# --- Execute upgrade --- + +test_start "Upgrade execution: update from 1.0.0-1 to 2.0.0" +UPGRADE_START=$(date +%s) +if start_async_and_wait \ + "/api/v1/instances/${INSTANCE}/apps/${APP_NAME}/update" "" "$DEPLOY_TIMEOUT"; then + UPGRADE_ELAPSED=$(( $(date +%s) - UPGRADE_START )) + test_pass + echo " Upgrade completed in ${UPGRADE_ELAPSED}s" +else + test_fail "Upgrade operation failed or timed out" +fi + +test_start "Upgrade execution: version is 2.0.0 after upgrade" +installed_version=$(grep '^version:' "$MANIFEST_PATH" | head -1 | awk '{print $2}') +assert_eq "$installed_version" "2.0.0" "Should be at latest version after upgrade" + +test_start "Upgrade execution: .versions/ not in .package" +if [[ -d "${DATA_DIR}/instances/${INSTANCE}/apps/${APP_NAME}/.package/.versions" ]]; then + test_fail ".versions/ directory leaked into .package" +else + test_pass +fi + +# --- Plan shows already current after upgrade --- + +test_start "Upgrade plan: already current after upgrade" +api_get "/api/v1/instances/${INSTANCE}/apps/${APP_NAME}/upgrade-plan" +steps=$(echo "$RESP" | jq -r '.steps | length' 2>/dev/null) +assert_eq "$steps" "0" "Should have 0 steps when already current" + +# --- Test blocked path --- +# Delete and re-add at a version below 1.0.0 to test blocked upgrade + +echo " Testing blocked upgrade path..." +start_async_delete_and_wait "/api/v1/instances/${INSTANCE}/apps/${APP_NAME}" "$DELETE_TIMEOUT" || true +sleep 5 + +# Add at latest, then modify version to simulate a pre-1.0.0 install +# (no waypoint exists for 0.9.0, so we add latest then patch the version) +api_post "/api/v1/instances/${INSTANCE}/apps" "{\"name\":\"${APP_NAME}\"}" +sed -i 's/^version: 2\.0\.0/version: 0.9.0/' "$MANIFEST_PATH" + +test_start "Upgrade plan: blocked for version < 1.0.0" +api_get "/api/v1/instances/${INSTANCE}/apps/${APP_NAME}/upgrade-plan" +blocked=$(echo "$RESP" | jq -r '.blocked' 2>/dev/null) +assert_eq "$blocked" "true" "Should be blocked for version below 1.0.0" + +test_start "Upgrade plan: blocked path includes notes" +notes=$(echo "$RESP" | jq -r '.notes' 2>/dev/null) +assert_contains "$notes" "not supported" "Should include block reason" + +# --- Cleanup: leave app in a clean state for subsequent tests --- + +echo " Cleaning up upgrade tests..." +start_async_delete_and_wait "/api/v1/instances/${INSTANCE}/apps/${APP_NAME}" "$DELETE_TIMEOUT" || true +sleep 5 + +# Re-add at latest for any subsequent test files that expect it +api_post "/api/v1/instances/${INSTANCE}/apps" "{\"name\":\"${APP_NAME}\"}" diff --git a/cli/cmd/app.go b/cli/cmd/app.go index c31a99d..41810e4 100644 --- a/cli/cmd/app.go +++ b/cli/cmd/app.go @@ -87,9 +87,14 @@ var appAddCmd = &cobra.Command{ return err } - _, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/apps", inst), map[string]string{ + body := map[string]string{ "name": args[0], - }) + } + if v, _ := cmd.Flags().GetString("version"); v != "" { + body["version"] = v + } + + _, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/apps", inst), body) if err != nil { return err } @@ -198,12 +203,63 @@ var appStatusCmd = &cobra.Command{ }, } +var appUpgradePlanCmd = &cobra.Command{ + Use: "upgrade-plan ", + Short: "Show the upgrade plan for an app", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inst, err := getInstanceName() + if err != nil { + return err + } + + resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/apps/%s/upgrade-plan", inst, args[0])) + if err != nil { + return err + } + + if outputFormat == "json" { + return printJSON(resp.Data) + } + + if resp.GetBool("blocked") { + fmt.Printf("Upgrade blocked: %s\n", resp.GetString("notes")) + return nil + } + + steps := resp.GetArray("steps") + if len(steps) == 0 { + fmt.Println("Already up to date.") + return nil + } + + fmt.Printf("Upgrade plan (%d step(s)):\n", len(steps)) + for i, s := range steps { + if step, ok := s.(map[string]interface{}); ok { + from, _ := step["fromVersion"].(string) + to, _ := step["toVersion"].(string) + fmt.Printf(" Step %d: %s -> %s\n", i+1, from, to) + } + } + + if resp.GetBool("backupRequired") { + fmt.Println("\nBackup required before upgrading.") + } else if resp.GetBool("backupRecommended") { + fmt.Println("\nBackup recommended before upgrading.") + } + + return nil + }, +} + func init() { appCmd.AddCommand(appListCmd) appCmd.AddCommand(appListDeployedCmd) appCmd.AddCommand(appAddCmd) + appAddCmd.Flags().String("version", "", "Install a specific version from .versions/ (e.g. '1.0.0-1')") appCmd.AddCommand(appDeployCmd) appCmd.AddCommand(appUpdateCmd) appCmd.AddCommand(appDeleteCmd) appCmd.AddCommand(appStatusCmd) + appCmd.AddCommand(appUpgradePlanCmd) } diff --git a/web/src/components/apps/AppDetailPanel.tsx b/web/src/components/apps/AppDetailPanel.tsx index 42931ad..0d155e6 100644 --- a/web/src/components/apps/AppDetailPanel.tsx +++ b/web/src/components/apps/AppDetailPanel.tsx @@ -353,7 +353,26 @@ export function AppDetailPanel({ )} - {updateAvailable && ( + {updateAvailable && sourceUpdate?.upgradeBlocked && ( +
+ + {sourceUpdate.upgradeNotes && ( + + {sourceUpdate.upgradeNotes} + + )} +
+ )} + + {updateAvailable && !sourceUpdate?.upgradeBlocked && ( )} diff --git a/web/src/services/api/apps.ts b/web/src/services/api/apps.ts index 655e21e..5ad1cf8 100644 --- a/web/src/services/api/apps.ts +++ b/web/src/services/api/apps.ts @@ -13,6 +13,7 @@ import type { RuntimeStatus, LogResponse, KubernetesEvent, + UpgradePlan, } from './types'; export const appsApi = { @@ -55,6 +56,10 @@ export const appsApi = { return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/update`); }, + async getUpgradePlan(instanceName: string, appName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/upgrade-plan`); + }, + async delete(instanceName: string, appName: string): Promise { return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`); }, diff --git a/web/src/services/api/types/app.ts b/web/src/services/api/types/app.ts index e239cca..6ada0d7 100644 --- a/web/src/services/api/types/app.ts +++ b/web/src/services/api/types/app.ts @@ -135,6 +135,9 @@ export interface StageDrift { reason?: string; currentVersion?: string; availableVersion?: string; + upgradeBlocked?: boolean; + upgradeNotes?: string; + upgradeSteps?: number; } export interface DriftInfo { @@ -170,6 +173,19 @@ export interface LogResponse { logs: LogLine[]; } +export interface UpgradeStep { + fromVersion: string; + toVersion: string; +} + +export interface UpgradePlan { + steps: UpgradeStep[]; + blocked: boolean; + notes?: string; + backupRequired?: boolean; + backupRecommended?: boolean; +} + export interface AppListResponse { apps: App[]; }