Files
wild-cloud/api/internal/apps/upgrade_test.go
2026-05-24 20:54:13 +00:00

676 lines
18 KiB
Go

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)
}
}
// writeAppMeta writes an AppMeta as app.yaml in the given app root directory.
func writeAppMeta(t *testing.T, appRoot string, meta AppMeta) {
t.Helper()
if err := os.MkdirAll(appRoot, 0755); err != nil {
t.Fatal(err)
}
data, err := yaml.Marshal(meta)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(appRoot, "app.yaml"), data, 0644); err != nil {
t.Fatal(err)
}
}
// writeVersionManifest writes a version manifest at versions/{version}/manifest.yaml.
func writeVersionManifest(t *testing.T, appRoot, version string, manifest AppManifest) {
t.Helper()
writeManifest(t, filepath.Join(appRoot, "versions", version), manifest)
}
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")
writeAppMeta(t, appDir, AppMeta{Name: "myapp", Latest: "1"})
writeVersionManifest(t, appDir, "1", AppManifest{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)
appDir := filepath.Join(tmpDir, "myapp")
writeAppMeta(t, appDir, AppMeta{Name: "myapp", Latest: "2"})
writeVersionManifest(t, appDir, "2", AppManifest{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")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "2",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">=1.0.0"},
},
},
})
writeVersionManifest(t, appDir, "2", AppManifest{Version: "2.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")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "3",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: "<1.0.0", Blocked: true, Notes: "too old, manual migration required"},
{Version: ">=1.0.0"},
},
},
})
writeVersionManifest(t, appDir, "3", AppManifest{Version: "3.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")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "3",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">=2.0.0"}, // only allows from 2.x+
},
},
})
writeVersionManifest(t, appDir, "3", AppManifest{Version: "3.0.0"})
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)
// Slot "3" has version 3.0.0, slot "2" (waypoint) has version 2.0.0
appDir := filepath.Join(tmpDir, "myapp")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "3",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">=2.0.0"}, // direct from 2.x
{Version: ">=1.0.0", Via: "2"},
},
},
})
writeVersionManifest(t, appDir, "3", AppManifest{Version: "3.0.0"})
writeVersionManifest(t, appDir, "2", AppManifest{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 (version from manifest in slot "2")
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 (version from manifest in slot "3")
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)
// Slots "2", "3", "4" with versions 2.0.0, 3.0.0, 4.0.0
// Centralized rules: 1.x → via slot "2", 2.x → via slot "3", 3.x → direct to slot "4"
appDir := filepath.Join(tmpDir, "myapp")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "4",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">=3.0.0"}, // direct from 3.x
{Version: ">=2.0.0", Via: "3"}, // 2.x must go through slot "3"
{Version: ">=1.0.0", Via: "2"}, // 1.x must go through slot "2"
},
},
})
writeVersionManifest(t, appDir, "4", AppManifest{Version: "4.0.0"})
writeVersionManifest(t, appDir, "3", AppManifest{Version: "3.0.0"})
writeVersionManifest(t, appDir, "2", AppManifest{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 (version from manifest in slot "2")
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 (version from manifest in slot "3")
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 (version from manifest in slot "4")
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)
// Two waypoints that bounce between each other: wp-a (1.5.0) and wp-b (1.2.0)
// 1.0.0 → wp-a (1.5.0) → wp-b (1.2.0) → wp-a (1.5.0) = cycle
appDir := filepath.Join(tmpDir, "myapp")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "3",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">=1.5.0", Via: "wp-b"},
{Version: ">0", Via: "wp-a"},
},
},
})
writeVersionManifest(t, appDir, "3", AppManifest{Version: "3.0.0"})
writeVersionManifest(t, appDir, "wp-a", AppManifest{Version: "1.5.0"})
writeVersionManifest(t, appDir, "wp-b", AppManifest{Version: "1.2.0"})
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")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "2",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">0"},
},
PreUpgrade: &PreUpgradeConfig{
Backup: "required",
},
},
})
writeVersionManifest(t, appDir, "2", AppManifest{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 !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")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "2",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">0"},
},
PreUpgrade: &PreUpgradeConfig{
Backup: "recommended",
},
},
})
writeVersionManifest(t, appDir, "2", AppManifest{Version: "2.0.0"})
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")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "2",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">0", Via: "1-wp"}, // waypoint slot doesn't exist
},
},
})
writeVersionManifest(t, appDir, "2", AppManifest{Version: "2.0.0"})
_, 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)
appDir := filepath.Join(tmpDir, "myapp")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "3",
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
},
},
})
writeVersionManifest(t, appDir, "3", AppManifest{Version: "3.0.0"})
// 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)
// App-level has no backup requirement, but waypoint manifest does
appDir := filepath.Join(tmpDir, "myapp")
writeAppMeta(t, appDir, AppMeta{
Name: "myapp",
Latest: "3",
Upgrade: &UpgradeConfig{
From: []UpgradeFromRule{
{Version: ">=2.0.0"},
{Version: ">=1.0.0", Via: "2"},
},
},
})
writeVersionManifest(t, appDir, "3", AppManifest{Version: "3.0.0"})
writeVersionManifest(t, appDir, "2", AppManifest{
Version: "2.0.0",
Upgrade: &UpgradeConfig{
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)")
}
}
// --- Old-style backward compatibility tests ---
func TestComputeUpgradePlan_OldStyle_DirectUpgrade(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "upgrade-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Old-style: manifest.yaml at root, no app.yaml
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_OldStyle_ViaWaypoint(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "upgrade-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Old-style: root manifest + .versions/ for waypoints
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",
})
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))
}
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)
}
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)
}
}