First version of app upgrade.

This commit is contained in:
2026-05-24 03:59:36 +00:00
parent 8e55a589fb
commit 9ac643a50f
15 changed files with 1389 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -353,7 +353,26 @@ export function AppDetailPanel({
</>
)}
{updateAvailable && (
{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"
@@ -363,6 +382,7 @@ export function AppDetailPanel({
>
{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>

View File

@@ -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<UpgradePlan> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/upgrade-plan`);
},
async delete(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`);
},

View File

@@ -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[];
}