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}/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}/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}/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}/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.AppsGetConfig).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/instances/{name}/apps/{app}/config", api.AppsUpdateConfig).Methods("PATCH")
|
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)
|
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))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add app: %v", err))
|
||||||
return
|
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) {
|
func (api *API) AppsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
instanceName := GetInstanceName(r)
|
instanceName := GetInstanceName(r)
|
||||||
appName := GetAppName(r)
|
appName := GetAppName(r)
|
||||||
|
|
||||||
api.startAppOperation(w, instanceName, appName, "update_app", "App updated",
|
api.StartAsyncOperationWithBroadcaster(w, instanceName, "update_app", appName,
|
||||||
func(mgr *apps.Manager, instance, app string) error {
|
func(opsMgr *operations.Manager, opID string, broadcaster *operations.Broadcaster) error {
|
||||||
return mgr.Update(instance, app)
|
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
|
// AppsEject converts an app from package-managed to custom
|
||||||
func (api *API) AppsEject(w http.ResponseWriter, r *http.Request) {
|
func (api *API) AppsEject(w http.ResponseWriter, r *http.Request) {
|
||||||
instanceName := GetInstanceName(r)
|
instanceName := GetInstanceName(r)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type IPRequest struct {
|
|||||||
// AppAddRequest is the request body for adding an app to an instance.
|
// AppAddRequest is the request body for adding an app to an instance.
|
||||||
type AppAddRequest struct {
|
type AppAddRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
Config map[string]interface{} `json:"config"`
|
Config map[string]interface{} `json:"config"`
|
||||||
RequiredAppMappings map[string]string `json:"requiredAppMappings"`
|
RequiredAppMappings map[string]string `json:"requiredAppMappings"`
|
||||||
Fetch bool `json:"fetch,omitempty"`
|
Fetch bool `json:"fetch,omitempty"`
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (m *Manager) ListAvailable(category string) ([]App, error) {
|
|||||||
|
|
||||||
apps := []App{}
|
apps := []App{}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,10 +528,17 @@ func setNestedConfig(yq *tools.YQ, configFile, basePath string, value interface{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add adds an app to the instance configuration
|
// Add adds an app to the instance configuration
|
||||||
func (m *Manager) Add(instanceName, appName string, config map[string]interface{}, requiredAppMappings map[string]string) error {
|
func (m *Manager) Add(instanceName, appName, version string, config map[string]interface{}, requiredAppMappings map[string]string) error {
|
||||||
// 1. Verify app exists
|
// 1. Verify app exists, optionally at a specific version
|
||||||
manifestPath := filepath.Join(m.appsDir, appName, "manifest.yaml")
|
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 !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)
|
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
|
// 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
|
// 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 {
|
if err := processConfigInOrder(sourceManifestPath, appName, configFile); err != nil {
|
||||||
return fmt.Errorf("failed to process config in order: %w", err)
|
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
|
// 5. Copy source files to .package directory first
|
||||||
sourceAppDir := filepath.Join(m.appsDir, appName)
|
|
||||||
entries, err := os.ReadDir(sourceAppDir)
|
entries, err := os.ReadDir(sourceAppDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read app directory: %w", err)
|
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 {
|
for _, entry := range entries {
|
||||||
|
if entry.Name() == ".versions" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sourcePath := filepath.Join(sourceAppDir, entry.Name())
|
sourcePath := filepath.Join(sourceAppDir, entry.Name())
|
||||||
packagePath := filepath.Join(packageDir, entry.Name())
|
packagePath := filepath.Join(packageDir, entry.Name())
|
||||||
|
|
||||||
@@ -1503,58 +1512,74 @@ func (m *Manager) Update(instanceName, appName string) error {
|
|||||||
var sourceAppDir string
|
var sourceAppDir string
|
||||||
switch sourceParts[0] {
|
switch sourceParts[0] {
|
||||||
case "file":
|
case "file":
|
||||||
// Local filesystem path
|
|
||||||
sourceAppDir = sourceParts[1]
|
sourceAppDir = sourceParts[1]
|
||||||
case "git+https", "git+http", "git+ssh":
|
case "git+https", "git+http", "git+ssh":
|
||||||
// Git repository - not yet implemented
|
|
||||||
return fmt.Errorf("git source not yet supported: %s", manifest.Source)
|
return fmt.Errorf("git source not yet supported: %s", manifest.Source)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported source protocol: %s", sourceParts[0])
|
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
|
// Copy new version to temp directory
|
||||||
tempDir := filepath.Join(appDestDir, ".package.new")
|
tempDir := filepath.Join(appDestDir, ".package.new")
|
||||||
if err := storage.EnsureDir(tempDir, 0755); err != nil {
|
if err := storage.EnsureDir(tempDir, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
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(sourceDir)
|
||||||
entries, err := os.ReadDir(sourceAppDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read source directory: %w", err)
|
return fmt.Errorf("failed to read source directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
if entry.Name() == ".versions" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
subSrcDir := filepath.Join(sourceAppDir, entry.Name())
|
if err := copyDir(filepath.Join(sourceDir, entry.Name()), filepath.Join(tempDir, entry.Name())); err != nil {
|
||||||
subDstDir := filepath.Join(tempDir, entry.Name())
|
|
||||||
if err := copyDir(subSrcDir, subDstDir); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy directory %s: %w", entry.Name(), err)
|
return fmt.Errorf("failed to copy directory %s: %w", entry.Name(), err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sourcePath := filepath.Join(sourceAppDir, entry.Name())
|
data, err := os.ReadFile(filepath.Join(sourceDir, entry.Name()))
|
||||||
tempPath := filepath.Join(tempDir, entry.Name())
|
|
||||||
|
|
||||||
data, err := os.ReadFile(sourcePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read %s: %w", entry.Name(), err)
|
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)
|
return fmt.Errorf("failed to write %s: %w", entry.Name(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace .package with new version, keeping old as backup
|
// Replace .package with new version, keeping old as backup
|
||||||
oldPackageDir := packageDir + ".old"
|
oldPackageDir := packageDir + ".old"
|
||||||
os.RemoveAll(oldPackageDir) // Clean up any leftover backup
|
os.RemoveAll(oldPackageDir)
|
||||||
if err := os.Rename(packageDir, oldPackageDir); err != nil {
|
if err := os.Rename(packageDir, oldPackageDir); err != nil {
|
||||||
return fmt.Errorf("failed to backup old package: %w", err)
|
return fmt.Errorf("failed to backup old package: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Rename(tempDir, packageDir); err != nil {
|
if err := os.Rename(tempDir, packageDir); err != nil {
|
||||||
// Restore backup on failure
|
|
||||||
os.Rename(oldPackageDir, packageDir)
|
os.Rename(oldPackageDir, packageDir)
|
||||||
return fmt.Errorf("failed to update package: %w", err)
|
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)
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||||
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
||||||
|
|
||||||
// rollback restores the old package if anything fails after the swap
|
|
||||||
rollback := func() {
|
rollback := func() {
|
||||||
os.RemoveAll(packageDir)
|
os.RemoveAll(packageDir)
|
||||||
os.Rename(oldPackageDir, 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
|
// Update local manifest with new version while preserving source and installedAs
|
||||||
newManifest.Source = manifest.Source
|
newManifest.Source = preserveSource
|
||||||
if len(manifest.Requires) > 0 {
|
if len(manifest.Requires) > 0 {
|
||||||
installedAsMap := make(map[string]string)
|
installedAsMap := make(map[string]string)
|
||||||
for _, req := range manifest.Requires {
|
for _, req := range manifest.Requires {
|
||||||
@@ -1643,6 +1667,191 @@ func (m *Manager) Update(instanceName, appName string) error {
|
|||||||
return nil
|
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
|
// Eject converts an app from package-managed to custom
|
||||||
func (m *Manager) Eject(instanceName, appName string) error {
|
func (m *Manager) Eject(instanceName, appName string) error {
|
||||||
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
||||||
@@ -1776,7 +1985,7 @@ func (m *Manager) compileFromPackage(appName, appDestDir, packageDir, configFile
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range packageEntries {
|
for _, entry := range packageEntries {
|
||||||
if entry.Name() == "manifest.yaml" {
|
if entry.Name() == "manifest.yaml" || strings.HasPrefix(entry.Name(), ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1887,6 +2096,9 @@ func (m *Manager) Fetch(instanceName, appName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
if entry.Name() == ".versions" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sourcePath := filepath.Join(sourceDir, entry.Name())
|
sourcePath := filepath.Join(sourceDir, entry.Name())
|
||||||
packagePath := filepath.Join(packageDir, 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
|
// Update the compiled manifest with metadata from the source manifest
|
||||||
// Preserves instance-specific fields (source, installedAs) while updating
|
// 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")
|
sourceManifestPath := filepath.Join(packageDir, "manifest.yaml")
|
||||||
if sourceData, err := os.ReadFile(sourceManifestPath); err == nil {
|
if sourceData, err := os.ReadFile(sourceManifestPath); err == nil {
|
||||||
var sourceManifest AppManifest
|
var sourceManifest AppManifest
|
||||||
@@ -1918,6 +2130,7 @@ func (m *Manager) Fetch(instanceName, appName string) error {
|
|||||||
manifest.Icon = sourceManifest.Icon
|
manifest.Icon = sourceManifest.Icon
|
||||||
manifest.Scripts = sourceManifest.Scripts
|
manifest.Scripts = sourceManifest.Scripts
|
||||||
manifest.Deploy = sourceManifest.Deploy
|
manifest.Deploy = sourceManifest.Deploy
|
||||||
|
manifest.Upgrade = sourceManifest.Upgrade
|
||||||
manifestYAML, err := yaml.Marshal(manifest)
|
manifestYAML, err := yaml.Marshal(manifest)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
storage.WriteFile(manifestPath, manifestYAML, 0644)
|
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) {
|
if fetch || !storage.FileExists(appDestDir) {
|
||||||
// Add (which includes fetch + compile)
|
// 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)
|
return fmt.Errorf("add failed: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ namespace: loomio
|
|||||||
|
|
||||||
// Create app manager and add the app
|
// Create app manager and add the app
|
||||||
manager := NewManager(dataDir, appsDir)
|
manager := NewManager(dataDir, appsDir)
|
||||||
err = manager.Add(instanceName, appName, nil, nil)
|
err = manager.Add(instanceName, appName, "", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add app: %v", err)
|
t.Fatalf("Failed to add app: %v", err)
|
||||||
}
|
}
|
||||||
@@ -555,7 +555,7 @@ namespace: testapp
|
|||||||
|
|
||||||
// Create app manager and add the app
|
// Create app manager and add the app
|
||||||
manager := NewManager(dataDir, appsDir)
|
manager := NewManager(dataDir, appsDir)
|
||||||
err = manager.Add(instanceName, appName, nil, nil)
|
err = manager.Add(instanceName, appName, "", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add app: %v", err)
|
t.Fatalf("Failed to add app: %v", err)
|
||||||
}
|
}
|
||||||
@@ -676,7 +676,7 @@ func TestSecretTemplateWithMultipleRandoms(t *testing.T) {
|
|||||||
|
|
||||||
// Create app manager and add the app
|
// Create app manager and add the app
|
||||||
manager := NewManager(dataDir, appsDir)
|
manager := NewManager(dataDir, appsDir)
|
||||||
err = manager.Add(instanceName, appName, nil, nil)
|
err = manager.Add(instanceName, appName, "", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add app: %v", err)
|
t.Fatalf("Failed to add app: %v", err)
|
||||||
}
|
}
|
||||||
@@ -824,7 +824,7 @@ resources:
|
|||||||
|
|
||||||
// Create app manager and add the app
|
// Create app manager and add the app
|
||||||
manager := NewManager(dataDir, appsDir)
|
manager := NewManager(dataDir, appsDir)
|
||||||
err = manager.Add(instanceName, appName, nil, nil)
|
err = manager.Add(instanceName, appName, "", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add app: %v", err)
|
t.Fatalf("Failed to add app: %v", err)
|
||||||
}
|
}
|
||||||
@@ -984,7 +984,7 @@ func TestExistingSecretsNotOverwritten(t *testing.T) {
|
|||||||
|
|
||||||
// Create app manager and add the app
|
// Create app manager and add the app
|
||||||
manager := NewManager(dataDir, appsDir)
|
manager := NewManager(dataDir, appsDir)
|
||||||
err = manager.Add(instanceName, appName, nil, nil)
|
err = manager.Add(instanceName, appName, "", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add app: %v", err)
|
t.Fatalf("Failed to add app: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1080,7 +1080,7 @@ defaultSecrets:
|
|||||||
|
|
||||||
// Add the app
|
// Add the app
|
||||||
mgr := NewManager(dataDir, appsDir)
|
mgr := NewManager(dataDir, appsDir)
|
||||||
err = mgr.Add(instanceName, appName, nil, nil)
|
err = mgr.Add(instanceName, appName, "", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add app: %v", err)
|
t.Fatalf("Failed to add app: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1239,7 +1239,7 @@ defaultConfig:
|
|||||||
|
|
||||||
// Add the app initially
|
// Add the app initially
|
||||||
mgr := NewManager(dataDir, appsDir)
|
mgr := NewManager(dataDir, appsDir)
|
||||||
err = mgr.Add(instanceName, appName, nil, nil)
|
err = mgr.Add(instanceName, appName, "", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add app: %v", err)
|
t.Fatalf("Failed to add app: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1662,7 +1662,7 @@ defaultConfig:
|
|||||||
|
|
||||||
// Add the app
|
// Add the app
|
||||||
mgr := NewManager(dataDir, appsDir)
|
mgr := NewManager(dataDir, appsDir)
|
||||||
err = mgr.Add(instanceName, appName, nil, nil)
|
err = mgr.Add(instanceName, appName, "", nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to add app: %v", err)
|
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
|
// Source and compilation drift only apply to source-managed apps
|
||||||
if manifest.Source != "" {
|
if manifest.Source != "" {
|
||||||
drift.Source = m.checkSourceDrift(manifest, packageDir)
|
drift.Source = m.checkSourceDrift(manifest, packageDir, appName)
|
||||||
if drift.Source != nil {
|
if drift.Source != nil {
|
||||||
hasAnyDrift = true
|
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.
|
// 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 == "" {
|
if manifest.Source == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -92,12 +92,22 @@ func (m *Manager) checkSourceDrift(manifest *AppManifest, packageDir string) *St
|
|||||||
}
|
}
|
||||||
|
|
||||||
if manifest.Version != sourceManifest.Version {
|
if manifest.Version != sourceManifest.Version {
|
||||||
return &StageDrift{
|
sd := &StageDrift{
|
||||||
Drifted: true,
|
Drifted: true,
|
||||||
Reason: fmt.Sprintf("update available: %s → %s", manifest.Version, sourceManifest.Version),
|
Reason: fmt.Sprintf("update available: %s → %s", manifest.Version, sourceManifest.Version),
|
||||||
CurrentVersion: manifest.Version,
|
CurrentVersion: manifest.Version,
|
||||||
AvailableVersion: sourceManifest.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
|
return nil
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func TestCheckSourceDrift_NoDrift(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m := &Manager{}
|
m := &Manager{}
|
||||||
result := m.checkSourceDrift(manifest, packageDir)
|
result := m.checkSourceDrift(manifest, packageDir, "myapp")
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Errorf("expected no drift, got %+v", result)
|
t.Errorf("expected no drift, got %+v", result)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func TestCheckSourceDrift_VersionDrift(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m := &Manager{}
|
m := &Manager{}
|
||||||
result := m.checkSourceDrift(manifest, packageDir)
|
result := m.checkSourceDrift(manifest, packageDir, "myapp")
|
||||||
if result == nil {
|
if result == nil {
|
||||||
t.Fatal("expected drift, got nil")
|
t.Fatal("expected drift, got nil")
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ func TestCheckSourceDrift_NoSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m := &Manager{}
|
m := &Manager{}
|
||||||
result := m.checkSourceDrift(manifest, "/nonexistent")
|
result := m.checkSourceDrift(manifest, "/nonexistent", "myapp")
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Errorf("expected nil for ejected app, got %+v", result)
|
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")
|
packageDir := filepath.Join(tmpDir, "nonexistent-package")
|
||||||
|
|
||||||
m := &Manager{}
|
m := &Manager{}
|
||||||
result := m.checkSourceDrift(manifest, packageDir)
|
result := m.checkSourceDrift(manifest, packageDir, "myapp")
|
||||||
if result == nil {
|
if result == nil {
|
||||||
t.Fatal("expected drift for missing package dir, got 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
|
packageDir := "/tmp" // exists but irrelevant
|
||||||
|
|
||||||
m := &Manager{}
|
m := &Manager{}
|
||||||
result := m.checkSourceDrift(manifest, packageDir)
|
result := m.checkSourceDrift(manifest, packageDir, "myapp")
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Errorf("expected nil when source dir is missing, got %+v", result)
|
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"`
|
Source string `json:"source,omitempty" yaml:"source,omitempty"`
|
||||||
Scripts []Script `json:"scripts,omitempty" yaml:"scripts,omitempty"`
|
Scripts []Script `json:"scripts,omitempty" yaml:"scripts,omitempty"`
|
||||||
Deploy *DeployConfig `json:"deploy,omitempty" yaml:"deploy,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
|
// DeployConfig declares deployment behavior in the manifest, replacing install.sh scripts
|
||||||
@@ -114,6 +115,37 @@ type StageDrift struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
CurrentVersion string `json:"currentVersion,omitempty"`
|
CurrentVersion string `json:"currentVersion,omitempty"`
|
||||||
AvailableVersion string `json:"availableVersion,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
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/apps", inst), map[string]string{
|
body := map[string]string{
|
||||||
"name": args[0],
|
"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 {
|
if err != nil {
|
||||||
return err
|
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() {
|
func init() {
|
||||||
appCmd.AddCommand(appListCmd)
|
appCmd.AddCommand(appListCmd)
|
||||||
appCmd.AddCommand(appListDeployedCmd)
|
appCmd.AddCommand(appListDeployedCmd)
|
||||||
appCmd.AddCommand(appAddCmd)
|
appCmd.AddCommand(appAddCmd)
|
||||||
|
appAddCmd.Flags().String("version", "", "Install a specific version from .versions/ (e.g. '1.0.0-1')")
|
||||||
appCmd.AddCommand(appDeployCmd)
|
appCmd.AddCommand(appDeployCmd)
|
||||||
appCmd.AddCommand(appUpdateCmd)
|
appCmd.AddCommand(appUpdateCmd)
|
||||||
appCmd.AddCommand(appDeleteCmd)
|
appCmd.AddCommand(appDeleteCmd)
|
||||||
appCmd.AddCommand(appStatusCmd)
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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" />}
|
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||||
Update to {availableVersion}
|
Update to {availableVersion}
|
||||||
|
{(sourceUpdate?.upgradeSteps ?? 0) > 1 && ` (${sourceUpdate?.upgradeSteps} steps)`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
RuntimeStatus,
|
RuntimeStatus,
|
||||||
LogResponse,
|
LogResponse,
|
||||||
KubernetesEvent,
|
KubernetesEvent,
|
||||||
|
UpgradePlan,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const appsApi = {
|
export const appsApi = {
|
||||||
@@ -55,6 +56,10 @@ export const appsApi = {
|
|||||||
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/update`);
|
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> {
|
async delete(instanceName: string, appName: string): Promise<OperationResponse> {
|
||||||
return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`);
|
return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ export interface StageDrift {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
currentVersion?: string;
|
currentVersion?: string;
|
||||||
availableVersion?: string;
|
availableVersion?: string;
|
||||||
|
upgradeBlocked?: boolean;
|
||||||
|
upgradeNotes?: string;
|
||||||
|
upgradeSteps?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DriftInfo {
|
export interface DriftInfo {
|
||||||
@@ -170,6 +173,19 @@ export interface LogResponse {
|
|||||||
logs: LogLine[];
|
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 {
|
export interface AppListResponse {
|
||||||
apps: App[];
|
apps: App[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user