First version of app upgrade.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
261
api/internal/apps/upgrade.go
Normal file
261
api/internal/apps/upgrade.go
Normal 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
|
||||
}
|
||||
}
|
||||
602
api/internal/apps/upgrade_test.go
Normal file
602
api/internal/apps/upgrade_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
105
api/test/e2e/tests/08-upgrade.sh
Normal file
105
api/test/e2e/tests/08-upgrade.sh
Normal 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}\"}"
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user