Better app state drift convergence.
This commit is contained in:
@@ -719,6 +719,15 @@ func (m *Manager) Deploy(instanceName, appName string, opID string, broadcaster
|
||||
return fmt.Errorf("app %s not found in instance (run 'wild app add %s' first)", appName, appName)
|
||||
}
|
||||
|
||||
// Auto-recompile if .package exists to ensure manifests reflect current config
|
||||
packageDir := filepath.Join(appDir, ".package")
|
||||
if storage.FileExists(packageDir) {
|
||||
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
if err := m.compileFromPackage(appName, appDir, packageDir, configFile, secretsFile); err != nil {
|
||||
return fmt.Errorf("failed to recompile before deploy: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load app manifest
|
||||
manifestPath := filepath.Join(appDir, "manifest.yaml")
|
||||
var manifest AppManifest
|
||||
@@ -1380,6 +1389,9 @@ func (m *Manager) GetEnhanced(instanceName, appName string) (*EnhancedApp, error
|
||||
}
|
||||
}
|
||||
|
||||
// Compute drift state
|
||||
enhanced.Drift = m.computeDrift(instanceName, appName, appDir, kubeconfigPath, enhanced.Status, enhanced.Manifest)
|
||||
|
||||
return enhanced, nil
|
||||
}
|
||||
|
||||
@@ -1665,22 +1677,17 @@ func (m *Manager) Eject(instanceName, appName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateConfig updates an app's configuration and recompiles if needed
|
||||
// UpdateConfig updates an app's configuration in config.yaml.
|
||||
// Does not recompile templates — Deploy() handles recompilation to ensure convergence.
|
||||
func (m *Manager) UpdateConfig(instanceName, appName string, config map[string]interface{}) error {
|
||||
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
||||
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
||||
appDestDir := filepath.Join(instancePath, "apps", appName)
|
||||
packageDir := filepath.Join(appDestDir, ".package")
|
||||
|
||||
// Update config
|
||||
yq := tools.NewYQ()
|
||||
configLock := configFile + ".lock"
|
||||
|
||||
if err := storage.WithLock(configLock, func() error {
|
||||
for key, value := range config {
|
||||
keyPath := fmt.Sprintf(".apps.%s.%s", appName, key)
|
||||
// Use setNestedConfig to handle both simple values and nested objects
|
||||
if err := setNestedConfig(yq, configFile, keyPath, value); err != nil {
|
||||
return fmt.Errorf("failed to set config %s: %w", key, err)
|
||||
}
|
||||
@@ -1690,13 +1697,6 @@ func (m *Manager) UpdateConfig(instanceName, appName string, config map[string]i
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-compile if app has .package
|
||||
if storage.FileExists(packageDir) {
|
||||
if err := m.compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile); err != nil {
|
||||
return fmt.Errorf("failed to recompile app templates: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1921,13 +1921,7 @@ func (m *Manager) Fetch(instanceName, appName string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-compile to update the app directory from the fetched package
|
||||
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
||||
if err := m.compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile); err != nil {
|
||||
return fmt.Errorf("failed to compile after fetch: %w", err)
|
||||
}
|
||||
|
||||
// Note: Does not auto-compile. Deploy() handles recompilation to ensure convergence.
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
240
api/internal/apps/drift.go
Normal file
240
api/internal/apps/drift.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/storage"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
||||
)
|
||||
|
||||
// computeDrift calculates drift state for an app across the three-stage pipeline.
|
||||
// Uses manifest.Source as the canonical signal for whether an app is source-managed.
|
||||
func (m *Manager) computeDrift(instanceName, appName, appDir, kubeconfigPath, status string, manifest *AppManifest) *DriftInfo {
|
||||
if manifest == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
||||
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
||||
packageDir := filepath.Join(appDir, ".package")
|
||||
|
||||
drift := &DriftInfo{}
|
||||
hasAnyDrift := false
|
||||
|
||||
// Source and compilation drift only apply to source-managed apps
|
||||
if manifest.Source != "" {
|
||||
drift.Source = m.checkSourceDrift(manifest, packageDir)
|
||||
if drift.Source != nil {
|
||||
hasAnyDrift = true
|
||||
}
|
||||
|
||||
// Compilation drift: only if .package/ exists (if missing, source drift covers it)
|
||||
if storage.FileExists(packageDir) {
|
||||
drift.Compilation = m.checkCompilationDrift(appName, appDir, packageDir, configFile, secretsFile)
|
||||
if drift.Compilation != nil {
|
||||
hasAnyDrift = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy drift only for deployed apps
|
||||
if status != "not-added" && status != "added" {
|
||||
drift.Deploy = checkDeployDrift(kubeconfigPath, appDir)
|
||||
if drift.Deploy != nil {
|
||||
hasAnyDrift = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAnyDrift {
|
||||
return nil
|
||||
}
|
||||
return drift
|
||||
}
|
||||
|
||||
// checkSourceDrift compares the installed app against the Wild Directory source.
|
||||
func (m *Manager) checkSourceDrift(manifest *AppManifest, packageDir string) *StageDrift {
|
||||
if manifest.Source == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse source URI to get filesystem path
|
||||
sourceDir := parseSourceDir(manifest.Source)
|
||||
if sourceDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If .package/ is missing entirely, that's source drift (fetch to resolve)
|
||||
if !storage.FileExists(packageDir) {
|
||||
return &StageDrift{
|
||||
Drifted: true,
|
||||
Reason: "package files missing — fetch from source to restore",
|
||||
}
|
||||
}
|
||||
|
||||
// Read source manifest version
|
||||
sourceManifestPath := filepath.Join(sourceDir, "manifest.yaml")
|
||||
if !storage.FileExists(sourceManifestPath) {
|
||||
return nil // Source no longer exists, nothing to compare against
|
||||
}
|
||||
|
||||
sourceData, err := os.ReadFile(sourceManifestPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var sourceManifest AppManifest
|
||||
if err := yaml.Unmarshal(sourceData, &sourceManifest); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if manifest.Version != sourceManifest.Version {
|
||||
return &StageDrift{
|
||||
Drifted: true,
|
||||
Reason: fmt.Sprintf("update available: %s → %s", manifest.Version, sourceManifest.Version),
|
||||
CurrentVersion: manifest.Version,
|
||||
AvailableVersion: sourceManifest.Version,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCompilationDrift compiles to a temp dir and compares against current compiled manifests.
|
||||
func (m *Manager) checkCompilationDrift(appName, appDir, packageDir, configFile, secretsFile string) *StageDrift {
|
||||
tempDir, err := os.MkdirTemp("", "drift-check-*")
|
||||
if err != nil {
|
||||
return nil // Can't check, skip gracefully
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Compile .package/ + config + secrets to temp dir
|
||||
if err := m.compileFromPackage(appName, tempDir, packageDir, configFile, secretsFile); err != nil {
|
||||
return nil // Compilation failed, skip gracefully
|
||||
}
|
||||
|
||||
// Compare each compiled file in temp dir against current app dir
|
||||
entries, err := os.ReadDir(packageDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == "manifest.yaml" {
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
if dirsDiffer(filepath.Join(tempDir, entry.Name()), filepath.Join(appDir, entry.Name())) {
|
||||
return &StageDrift{
|
||||
Drifted: true,
|
||||
Reason: "configuration changed — redeploy to apply",
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if filesDiffer(filepath.Join(tempDir, entry.Name()), filepath.Join(appDir, entry.Name())) {
|
||||
return &StageDrift{
|
||||
Drifted: true,
|
||||
Reason: "configuration changed — redeploy to apply",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDeployDrift runs kubectl diff to compare compiled manifests against cluster state.
|
||||
func checkDeployDrift(kubeconfigPath, appDir string) *StageDrift {
|
||||
kubectl := tools.NewKubectl(kubeconfigPath)
|
||||
hasDiff, err := kubectl.Diff(appDir)
|
||||
if err != nil {
|
||||
return nil // kubectl diff failed (cluster unreachable, etc.), skip gracefully
|
||||
}
|
||||
|
||||
if hasDiff {
|
||||
return &StageDrift{
|
||||
Drifted: true,
|
||||
Reason: "cluster state differs from manifests",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSourceDir extracts the filesystem path from a source URI.
|
||||
// Returns empty string for unsupported schemes.
|
||||
func parseSourceDir(source string) string {
|
||||
parts := strings.SplitN(source, "://", 2)
|
||||
if len(parts) != 2 || parts[0] != "file" {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
// filesDiffer returns true if two files have different contents, or if either doesn't exist.
|
||||
func filesDiffer(pathA, pathB string) bool {
|
||||
a, errA := os.ReadFile(pathA)
|
||||
b, errB := os.ReadFile(pathB)
|
||||
if errA != nil || errB != nil {
|
||||
return (errA != nil) != (errB != nil) // Both missing = same, one missing = different
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return true
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dirsDiffer recursively compares two directories.
|
||||
func dirsDiffer(dirA, dirB string) bool {
|
||||
entriesA, errA := os.ReadDir(dirA)
|
||||
entriesB, errB := os.ReadDir(dirB)
|
||||
if errA != nil || errB != nil {
|
||||
return errA != errB
|
||||
}
|
||||
|
||||
namesA := make(map[string]bool, len(entriesA))
|
||||
for _, e := range entriesA {
|
||||
namesA[e.Name()] = e.IsDir()
|
||||
}
|
||||
|
||||
namesB := make(map[string]bool, len(entriesB))
|
||||
for _, e := range entriesB {
|
||||
namesB[e.Name()] = e.IsDir()
|
||||
}
|
||||
|
||||
// Check for files in A not in B or different
|
||||
for name, isDir := range namesA {
|
||||
isDirB, exists := namesB[name]
|
||||
if !exists || isDir != isDirB {
|
||||
return true
|
||||
}
|
||||
if isDir {
|
||||
if dirsDiffer(filepath.Join(dirA, name), filepath.Join(dirB, name)) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if filesDiffer(filepath.Join(dirA, name), filepath.Join(dirB, name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for files in B not in A
|
||||
for name := range namesB {
|
||||
if _, exists := namesA[name]; !exists {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
314
api/internal/apps/drift_test.go
Normal file
314
api/internal/apps/drift_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestParseSourceDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{"file URI", "file:///opt/wild-cloud/apps/myapp", "/opt/wild-cloud/apps/myapp"},
|
||||
{"empty string", "", ""},
|
||||
{"no scheme", "/opt/wild-cloud/apps/myapp", ""},
|
||||
{"unsupported scheme", "git://example.com/repo", ""},
|
||||
{"http scheme", "http://example.com/app", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseSourceDir(tt.source)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseSourceDir(%q) = %q, want %q", tt.source, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesDiffer(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "drift-files-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
fileA := filepath.Join(tmpDir, "a.txt")
|
||||
fileB := filepath.Join(tmpDir, "b.txt")
|
||||
fileC := filepath.Join(tmpDir, "c.txt")
|
||||
fileMissing := filepath.Join(tmpDir, "missing.txt")
|
||||
|
||||
os.WriteFile(fileA, []byte("hello"), 0644)
|
||||
os.WriteFile(fileB, []byte("hello"), 0644)
|
||||
os.WriteFile(fileC, []byte("world"), 0644)
|
||||
|
||||
t.Run("identical files", func(t *testing.T) {
|
||||
if filesDiffer(fileA, fileB) {
|
||||
t.Error("expected identical files to not differ")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different files", func(t *testing.T) {
|
||||
if !filesDiffer(fileA, fileC) {
|
||||
t.Error("expected different files to differ")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one missing", func(t *testing.T) {
|
||||
if !filesDiffer(fileA, fileMissing) {
|
||||
t.Error("expected missing file to differ")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both missing", func(t *testing.T) {
|
||||
if filesDiffer(fileMissing, filepath.Join(tmpDir, "also-missing.txt")) {
|
||||
t.Error("expected both missing to not differ")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDirsDiffer(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "drift-dirs-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create two identical directories
|
||||
dirA := filepath.Join(tmpDir, "a")
|
||||
dirB := filepath.Join(tmpDir, "b")
|
||||
os.MkdirAll(dirA, 0755)
|
||||
os.MkdirAll(dirB, 0755)
|
||||
os.WriteFile(filepath.Join(dirA, "file.txt"), []byte("same"), 0644)
|
||||
os.WriteFile(filepath.Join(dirB, "file.txt"), []byte("same"), 0644)
|
||||
|
||||
t.Run("identical directories", func(t *testing.T) {
|
||||
if dirsDiffer(dirA, dirB) {
|
||||
t.Error("expected identical directories to not differ")
|
||||
}
|
||||
})
|
||||
|
||||
// Create a directory with different content
|
||||
dirC := filepath.Join(tmpDir, "c")
|
||||
os.MkdirAll(dirC, 0755)
|
||||
os.WriteFile(filepath.Join(dirC, "file.txt"), []byte("different"), 0644)
|
||||
|
||||
t.Run("different content", func(t *testing.T) {
|
||||
if !dirsDiffer(dirA, dirC) {
|
||||
t.Error("expected directories with different content to differ")
|
||||
}
|
||||
})
|
||||
|
||||
// Directory with extra file
|
||||
dirD := filepath.Join(tmpDir, "d")
|
||||
os.MkdirAll(dirD, 0755)
|
||||
os.WriteFile(filepath.Join(dirD, "file.txt"), []byte("same"), 0644)
|
||||
os.WriteFile(filepath.Join(dirD, "extra.txt"), []byte("extra"), 0644)
|
||||
|
||||
t.Run("extra file in second", func(t *testing.T) {
|
||||
if !dirsDiffer(dirA, dirD) {
|
||||
t.Error("expected directories with different file counts to differ")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckSourceDrift_NoDrift(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "drift-source-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create source directory with manifest
|
||||
sourceDir := filepath.Join(tmpDir, "source", "myapp")
|
||||
os.MkdirAll(sourceDir, 0755)
|
||||
sourceManifest := AppManifest{Version: "1.0.0"}
|
||||
data, _ := yaml.Marshal(sourceManifest)
|
||||
os.WriteFile(filepath.Join(sourceDir, "manifest.yaml"), data, 0644)
|
||||
|
||||
// Create package dir (it exists)
|
||||
packageDir := filepath.Join(tmpDir, "package")
|
||||
os.MkdirAll(packageDir, 0755)
|
||||
|
||||
// Installed manifest with same version
|
||||
manifest := &AppManifest{
|
||||
Version: "1.0.0",
|
||||
Source: "file://" + sourceDir,
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
result := m.checkSourceDrift(manifest, packageDir)
|
||||
if result != nil {
|
||||
t.Errorf("expected no drift, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSourceDrift_VersionDrift(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "drift-source-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create source directory with newer version
|
||||
sourceDir := filepath.Join(tmpDir, "source", "myapp")
|
||||
os.MkdirAll(sourceDir, 0755)
|
||||
sourceManifest := AppManifest{Version: "2.0.0"}
|
||||
data, _ := yaml.Marshal(sourceManifest)
|
||||
os.WriteFile(filepath.Join(sourceDir, "manifest.yaml"), data, 0644)
|
||||
|
||||
// Create package dir
|
||||
packageDir := filepath.Join(tmpDir, "package")
|
||||
os.MkdirAll(packageDir, 0755)
|
||||
|
||||
// Installed manifest with older version
|
||||
manifest := &AppManifest{
|
||||
Version: "1.0.0",
|
||||
Source: "file://" + sourceDir,
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
result := m.checkSourceDrift(manifest, packageDir)
|
||||
if result == nil {
|
||||
t.Fatal("expected drift, got nil")
|
||||
}
|
||||
if !result.Drifted {
|
||||
t.Error("expected Drifted to be true")
|
||||
}
|
||||
if result.CurrentVersion != "1.0.0" {
|
||||
t.Errorf("expected CurrentVersion '1.0.0', got %q", result.CurrentVersion)
|
||||
}
|
||||
if result.AvailableVersion != "2.0.0" {
|
||||
t.Errorf("expected AvailableVersion '2.0.0', got %q", result.AvailableVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSourceDrift_NoSource(t *testing.T) {
|
||||
manifest := &AppManifest{
|
||||
Version: "1.0.0",
|
||||
Source: "", // ejected app
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
result := m.checkSourceDrift(manifest, "/nonexistent")
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for ejected app, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSourceDrift_PackageMissing(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "drift-source-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Source exists but .package/ does not
|
||||
sourceDir := filepath.Join(tmpDir, "source", "myapp")
|
||||
os.MkdirAll(sourceDir, 0755)
|
||||
|
||||
manifest := &AppManifest{
|
||||
Version: "1.0.0",
|
||||
Source: "file://" + sourceDir,
|
||||
}
|
||||
|
||||
packageDir := filepath.Join(tmpDir, "nonexistent-package")
|
||||
|
||||
m := &Manager{}
|
||||
result := m.checkSourceDrift(manifest, packageDir)
|
||||
if result == nil {
|
||||
t.Fatal("expected drift for missing package dir, got nil")
|
||||
}
|
||||
if !result.Drifted {
|
||||
t.Error("expected Drifted to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSourceDrift_SourceDirMissing(t *testing.T) {
|
||||
// Source URI points to a directory that doesn't exist
|
||||
manifest := &AppManifest{
|
||||
Version: "1.0.0",
|
||||
Source: "file:///nonexistent/path/myapp",
|
||||
}
|
||||
|
||||
packageDir := "/tmp" // exists but irrelevant
|
||||
|
||||
m := &Manager{}
|
||||
result := m.checkSourceDrift(manifest, packageDir)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil when source dir is missing, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDrift_EjectedApp(t *testing.T) {
|
||||
// Ejected app: Source is empty, so source and compilation drift should be nil
|
||||
manifest := &AppManifest{
|
||||
Version: "1.0.0",
|
||||
Source: "",
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
result := m.computeDrift("test-instance", "myapp", "/tmp", "", "added", manifest)
|
||||
|
||||
// For an ejected app with status "added", nothing to check — should be nil
|
||||
if result != nil {
|
||||
t.Errorf("expected nil drift for ejected 'added' app, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDrift_NotDeployed(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "drift-compute-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Source-managed app that is only "added" (not deployed)
|
||||
sourceDir := filepath.Join(tmpDir, "source")
|
||||
os.MkdirAll(sourceDir, 0755)
|
||||
|
||||
// Source manifest with newer version
|
||||
sourceManifest := AppManifest{Version: "2.0.0"}
|
||||
data, _ := yaml.Marshal(sourceManifest)
|
||||
os.WriteFile(filepath.Join(sourceDir, "manifest.yaml"), data, 0644)
|
||||
|
||||
// App directory with .package
|
||||
appDir := filepath.Join(tmpDir, "app")
|
||||
packageDir := filepath.Join(appDir, ".package")
|
||||
os.MkdirAll(packageDir, 0755)
|
||||
|
||||
manifest := &AppManifest{
|
||||
Version: "1.0.0",
|
||||
Source: "file://" + sourceDir,
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
result := m.computeDrift("test-instance", "myapp", appDir, "", "added", manifest)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("expected drift info, got nil")
|
||||
}
|
||||
|
||||
// Should have source drift (version mismatch)
|
||||
if result.Source == nil || !result.Source.Drifted {
|
||||
t.Error("expected source drift for version mismatch")
|
||||
}
|
||||
|
||||
// Should NOT have deploy drift (status is "added")
|
||||
if result.Deploy != nil {
|
||||
t.Error("expected no deploy drift for 'added' status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDrift_NilManifest(t *testing.T) {
|
||||
m := &Manager{}
|
||||
result := m.computeDrift("test-instance", "myapp", "/tmp", "", "running", nil)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil drift for nil manifest, got %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -85,18 +85,35 @@ type AppDependency struct {
|
||||
|
||||
// EnhancedApp extends DeployedApp with runtime status information
|
||||
type EnhancedApp struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Namespace string `json:"namespace"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Manifest *AppManifest `json:"manifest,omitempty"`
|
||||
Runtime *RuntimeStatus `json:"runtime,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Namespace string `json:"namespace"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Manifest *AppManifest `json:"manifest,omitempty"`
|
||||
Runtime *RuntimeStatus `json:"runtime,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
Readme string `json:"readme,omitempty"`
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
Drift *DriftInfo `json:"drift,omitempty"`
|
||||
Readme string `json:"readme,omitempty"`
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
}
|
||||
|
||||
// DriftInfo describes divergence across the three-stage pipeline:
|
||||
// Wild Directory → .package/ → compiled manifests → cluster
|
||||
type DriftInfo struct {
|
||||
Source *StageDrift `json:"source,omitempty"`
|
||||
Compilation *StageDrift `json:"compilation,omitempty"`
|
||||
Deploy *StageDrift `json:"deploy,omitempty"`
|
||||
}
|
||||
|
||||
// StageDrift represents drift at one pipeline stage
|
||||
type StageDrift struct {
|
||||
Drifted bool `json:"drifted"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CurrentVersion string `json:"currentVersion,omitempty"`
|
||||
AvailableVersion string `json:"availableVersion,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeStatus contains runtime information from kubernetes
|
||||
|
||||
@@ -996,3 +996,24 @@ func formatMemory(bytes int64) string {
|
||||
units := []string{"Ki", "Mi", "Gi", "Ti"}
|
||||
return fmt.Sprintf("%.1f%s", float64(bytes)/float64(div), units[exp])
|
||||
}
|
||||
|
||||
// Diff runs kubectl diff against a kustomize directory.
|
||||
// Returns (hasDiff, err): false/nil = cluster matches, true/nil = differs, false/err = command error.
|
||||
func (k *Kubectl) Diff(kustomizeDir string) (bool, error) {
|
||||
args := []string{"diff", "-k", kustomizeDir}
|
||||
if k.kubeconfigPath != "" {
|
||||
args = append([]string{"--kubeconfig", k.kubeconfigPath}, args...)
|
||||
}
|
||||
|
||||
cmd := exec.Command("kubectl", args...)
|
||||
_, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return false, nil // exit 0 = no diff
|
||||
}
|
||||
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return true, nil // exit 1 = has diff
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("kubectl diff failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.516.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
|
||||
819
web/pnpm-lock.yaml
generated
819
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user