Better app state drift convergence.

This commit is contained in:
2026-05-23 20:05:25 +00:00
parent d15ce9fcbe
commit cd31e6a365
7 changed files with 1438 additions and 32 deletions

View File

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff