package appconfig import ( "context" "errors" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) type pausedConfigWork struct { valid atomic.Bool once sync.Once entered chan struct{} resume chan struct{} } func newPausedConfigWork() *pausedConfigWork { w := &pausedConfigWork{entered: make(chan struct{}), resume: make(chan struct{})} w.valid.Store(true) return w } func (w *pausedConfigWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { if id.Config == "" { return errors.New("missing rendered identity") } w.once.Do(func() { close(w.entered) <-w.resume }) if !w.valid.Load() { return errors.New("selection changed") } return fn() } func TestSyncExecChangedSelectionBeforeMutationWritesNothing(t *testing.T) { dir := t.TempDir() host := "app.example" rt := registry.Route{Host: host, Account: "bab", App: "app"} w := newPausedConfigWork() bounces := atomic.Int64{} done := make(chan error, 1) go func() { done <- syncExecHostClaimed(t.Context(), w, rt, &Manifest{Vars: map[string]Var{"A": {Kind: "env", Value: "one"}}}, Key("bab", "app"), nil, dir, func(string) error { bounces.Add(1) return nil }, "", "", nil) }() <-w.entered w.valid.Store(false) close(w.resume) if err := <-done; err == nil { t.Fatal("stale config write succeeded") } if _, err := os.Stat(filepath.Join(dir, host+configEnvSuffix)); !os.IsNotExist(err) { t.Fatalf("config file exists after refusal: %v", err) } if bounces.Load() != 0 { t.Fatal("stale config bounced process") } } func TestSyncExecChangedSelectionBeforeRemovalPreservesBytes(t *testing.T) { dir := t.TempDir() host := "app.example" path := filepath.Join(dir, host+configEnvSuffix) if err := os.WriteFile(path, []byte("KEEP=1\n"), 0o600); err != nil { t.Fatal(err) } w := newPausedConfigWork() done := make(chan error, 1) go func() { done <- syncExecHostClaimed(t.Context(), w, registry.Route{Host: host, Account: "bab", App: "app"}, nil, Key("bab", "app"), nil, dir, func(string) error { return nil }, "", "", nil) }() <-w.entered w.valid.Store(false) close(w.resume) if err := <-done; err == nil { t.Fatal("stale removal succeeded") } if got, err := os.ReadFile(path); err != nil || string(got) != "KEEP=1\n" { t.Fatalf("preserved config = %q, %v", got, err) } } func TestSyncEnabledMissingProviderRefusesBeforeConfigReads(t *testing.T) { reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) if err := reg.Upsert(registry.Route{Host: "app.example", Kind: "instance", Account: "bab", App: "app", Backing: registry.BackingUnit, Status: "ready"}); err != nil { t.Fatal(err) } err = Sync(t.Context(), reg, nil, nil, t.TempDir(), func(string) error { return nil }, nil, hostwrite.Config{Enabled: true}) if !errors.Is(err, hostwrite.ErrUnavailable) { t.Fatalf("Sync missing provider = %v", err) } } func TestSyncRejectsMultipleHostWriteConfigurations(t *testing.T) { reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) if err := Sync(context.Background(), reg, nil, nil, t.TempDir(), nil, nil, hostwrite.Config{}, hostwrite.Config{}); !errors.Is(err, errHostWriteOptions) { t.Fatalf("multiple configs = %v", err) } } func TestClaimDisabledConfigErrorContinuesHostsAndReaps(t *testing.T) { for _, exec := range []bool{false, true} { t.Run(map[bool]string{false: "unit", true: "exec"}[exec], func(t *testing.T) { reg := testRegistry(t) defer reg.Close() store := release.NewDirStore(t.TempDir()) dir := t.TempDir() if err := SetEnv(t.Context(), store, "bab", "app", map[string]string{"VALUE": "new"}); err != nil { t.Fatal(err) } suffix := ".env" backing := registry.BackingUnit if exec { suffix = configEnvSuffix backing = "" } for _, host := range []string{"a.example", "b.example"} { if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "bab", App: "app", Backing: backing, Status: "ready", Socket: "/unused.sock"}); err != nil { t.Fatal(err) } } // A directory at the temporary-file path produces a deterministic error even as root. if err := os.Mkdir(filepath.Join(dir, "a.example"+suffix+".tmp"), 0700); err != nil { t.Fatal(err) } stale := filepath.Join(dir, "gone.example"+suffix) if err := os.WriteFile(stale, []byte("old"), 0600); err != nil { t.Fatal(err) } calls := map[string]int{} restart := func(host string) error { calls[host]++; return nil } var err error callbacks := Callbacks{Restart: restart, SelectedRestart: func(context.Context, hostwrite.Selection) error { t.Fatal("disabled selected restart"); return nil }, SelectedAck: func(context.Context, hostwrite.Selection, ConfigAck) error { t.Fatal("disabled selected ack") return nil }, } if exec { err = SyncExecWithCallbacks(t.Context(), reg, store, nil, dir, "", callbacks) } else { err = SyncWithCallbacks(t.Context(), reg, store, nil, dir, callbacks) } if err != nil { t.Fatalf("legacy pass aborted: %v", err) } if _, err := os.Stat(filepath.Join(dir, "b.example"+suffix)); err != nil || calls["b.example"] != 1 { t.Fatalf("later host: %v calls=%v", err, calls) } if _, err := os.Stat(stale); !os.IsNotExist(err) { t.Fatalf("reap skipped: %v", err) } }) } } type configTestSelector struct{} func (configTestSelector) Select(context.Context, string, string) (hostwrite.Work, error) { return legacyHostWork{}, nil } func TestClaimEnabledConfigReadAndRenderFailures(t *testing.T) { for _, shape := range []string{"unit", "flat-exec", "nested-exec"} { for _, failure := range []string{"read", "render"} { t.Run(shape+"/"+failure, func(t *testing.T) { reg := testRegistry(t) defer reg.Close() store := release.NewDirStore(t.TempDir()) dir := t.TempDir() rt := registry.Route{Host: "app.example", Kind: "instance", Account: "bab", App: "app", Status: "ready", Socket: "/unused.sock"} path := filepath.Join(dir, rt.Host+".env") if shape == "unit" { rt.Backing = registry.BackingUnit } else { if shape == "nested-exec" { rt.DBPath = provision.NestedDBPath(dir, rt.Host) rt.Socket = provision.NestedSocket(rt.Host) } path = ConfigEnvPath(dir, rt) } if err := reg.Upsert(rt); err != nil { t.Fatal(err) } vars := map[string]string{"VALUE": "new"} if err := SetEnv(t.Context(), store, "bab", "app", vars); err != nil { t.Fatal(err) } if failure == "read" { if err := os.MkdirAll(path, 0700); err != nil { t.Fatal(err) } } else { _, etag, err := store.Get(t.Context(), Key("bab", "app")) if err != nil { t.Fatal(err) } if _, err := store.Put(t.Context(), Key("bab", "app"), []byte(`{"schema":1,"vars":{"VALUE":{"kind":"secret"}}}`), etag); err != nil { t.Fatal(err) } } callbacks := 0 restart := func(string) error { callbacks++; return nil } ack := func(ConfigAck) { callbacks++ } writes := hostwrite.Config{Enabled: true, Selector: configTestSelector{}} var err error if shape == "unit" { err = Sync(t.Context(), reg, store, nil, dir, restart, ack, writes) } else { err = SyncExec(t.Context(), reg, store, nil, dir, restart, "", ack, WithHostWrite(writes)) } if err == nil { t.Fatal("enabled pass reported success after config " + failure + " failure") } if callbacks != 0 { t.Fatalf("callbacks after failure: %d", callbacks) } }) } } } func TestClaimConfigRefusesHostnameOnlyCallbacks(t *testing.T) { for _, shape := range []string{"unit", "exec", "exec-removal", "ack"} { t.Run(shape, func(t *testing.T) { reg := testRegistry(t) defer reg.Close() store := release.NewDirStore(t.TempDir()) dir := t.TempDir() rt := registry.Route{Host: "app.example", Kind: "instance", Account: "bab", App: "app", Status: "ready", Socket: "/unused.sock"} if shape == "unit" { rt.Backing = registry.BackingUnit } if shape == "ack" { rt.Socket = "" rt.Addr = "127.0.0.1:9999" } if err := reg.Upsert(rt); err != nil { t.Fatal(err) } if shape == "exec-removal" { if err := os.WriteFile(ConfigEnvPath(dir, rt), []byte("OLD=1\n"), 0600); err != nil { t.Fatal(err) } } else if err := SetEnv(t.Context(), store, "bab", "app", map[string]string{"VALUE": "new"}); err != nil { t.Fatal(err) } if shape == "ack" { if err := SetExecDelivery(t.Context(), store, "bab", "app", ExecDeliveryFile); err != nil { t.Fatal(err) } } calls := 0 restart := func(string) error { calls++; return nil } ack := func(ConfigAck) { calls++ } writes := hostwrite.Config{Enabled: true, Selector: configTestSelector{}} var err error if shape == "unit" { err = Sync(t.Context(), reg, store, nil, dir, restart, ack, writes) } else { err = SyncExec(t.Context(), reg, store, nil, dir, restart, "", ack, WithHostWrite(writes)) } if calls != 0 { t.Fatalf("enabled callbacks lost selection: %d effects, err=%v", calls, err) } if !errors.Is(err, hostwrite.ErrUnavailable) { t.Fatalf("missing selected callback: %v", err) } }) } } type callbackConfigWork struct { valid bool held bool } func (w *callbackConfigWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { if !w.valid { return errors.New("selection changed") } if w.held || id.Config == "" { return errors.New("invalid callback guard") } w.held = true defer func() { w.held = false }() return fn() } type selectedConfigTestSelector struct { work hostwrite.Work calls int } func (s *selectedConfigTestSelector) Select(context.Context, string, string) (hostwrite.Work, error) { s.calls++ return s.work, nil } func TestClaimConfigCallbacksRetainSelection(t *testing.T) { for _, shape := range []string{"unit", "exec", "exec-removal"} { for _, boundary := range []string{"restart", "ack", "current"} { if shape == "exec-removal" && boundary == "ack" { continue } t.Run(shape+"/"+boundary, func(t *testing.T) { reg := testRegistry(t) defer reg.Close() store := release.NewDirStore(t.TempDir()) dir := t.TempDir() rt := registry.Route{Host: "app.example", Kind: "instance", Account: "bab", App: "app", Status: "ready", Socket: "/unused.sock"} if shape == "unit" { rt.Backing = registry.BackingUnit } if err := reg.Upsert(rt); err != nil { t.Fatal(err) } if shape == "exec-removal" { if err := os.WriteFile(ConfigEnvPath(dir, rt), []byte("OLD=1\n"), 0600); err != nil { t.Fatal(err) } } else if err := SetEnv(t.Context(), store, "bab", "app", map[string]string{"VALUE": "new"}); err != nil { t.Fatal(err) } w := &callbackConfigWork{valid: true} selector := &selectedConfigTestSelector{work: w} restarts, acks := 0, 0 var identity hostwrite.Identity check := func(s hostwrite.Selection) { if w.held { t.Fatal("callback/network wait under guard") } if s.Work != w || s.Host != rt.Host || s.Identity.Config == "" { t.Fatalf("lost selection: %+v", s) } if identity.Config != "" && s.Identity != identity { t.Fatal("identity changed between callbacks") } identity = s.Identity } callbacks := Callbacks{ Restart: func(string) error { t.Fatal("legacy restart"); return nil }, Ack: func(ConfigAck) { t.Fatal("legacy ack") }, SelectedRestart: func(ctx context.Context, s hostwrite.Selection) error { check(s) if boundary == "restart" { w.valid = false } return s.With(ctx, func() error { restarts++; return nil }) }, SelectedAck: func(ctx context.Context, s hostwrite.Selection, a ConfigAck) error { check(s) if a.Host != rt.Host { t.Fatal(a.Host) } if boundary == "ack" { w.valid = false } return s.With(ctx, func() error { acks++; return nil }) }, } writes := hostwrite.Config{Enabled: true, Selector: selector} var err error if shape == "unit" { err = SyncWithCallbacks(t.Context(), reg, store, nil, dir, callbacks, writes) } else { err = SyncExecWithCallbacks(t.Context(), reg, store, nil, dir, "", callbacks, WithHostWrite(writes)) } wantRestarts, wantAcks := 1, 1 if shape == "exec-removal" { wantAcks = 0 } if boundary == "restart" { wantRestarts = 0 wantAcks = 0 } if boundary == "ack" { wantAcks = 0 } if (err == nil) != (boundary == "current") || restarts != wantRestarts || acks != wantAcks || selector.calls != 1 { t.Fatalf("err=%v restarts=%d acks=%d selections=%d", err, restarts, acks, selector.calls) } }) } } } type fencedConfigTestSelector struct { t *testing.T held string selected []string } func (s *fencedConfigTestSelector) Select(_ context.Context, host, _ string) (hostwrite.Work, error) { if host == s.held { s.t.Fatal("fenced host selected for guarded work") } s.selected = append(s.selected, host) return legacyHostWork{}, nil } func TestSyncExecFencedHostWithHostWrites(t *testing.T) { for _, mode := range []string{"default", "disabled", "enabled"} { for _, api := range []string{"legacy", "callbacks"} { for _, shape := range []string{"flat", "nested"} { for _, operation := range []string{"write", "remove"} { t.Run(mode+"/"+api+"/"+shape+"/"+operation, func(t *testing.T) { reg := testRegistry(t) defer reg.Close() store := release.NewDirStore(t.TempDir()) dir := t.TempDir() held, sibling := "held.example", "sibling.example" for _, host := range []string{held, sibling} { rt := registry.Route{Host: host, Kind: "instance", Account: "bab", App: strings.TrimSuffix(host, ".example"), Status: "ready", Socket: "/unused.sock"} if host == held && shape == "nested" { rt.DBPath = provision.NestedDBPath(dir, host) rt.Socket = provision.NestedSocket(host) } if err := reg.Upsert(rt); err != nil { t.Fatal(err) } if host == sibling || operation == "write" { if err := SetEnv(t.Context(), store, "bab", rt.App, map[string]string{"VALUE": "new"}); err != nil { t.Fatal(err) } } } paths := []string{filepath.Join(dir, held+configEnvSuffix), filepath.Join(provision.InstanceDir(dir, held), held+configEnvSuffix)} if mode == "enabled" { paths = append(paths, filepath.Join(dir, held+configEnvSuffix+".tmp")) } for _, path := range paths { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("KEEP=1\n"), 0600); err != nil { t.Fatal(err) } } selector := &fencedConfigTestSelector{t: t, held: held} opts := []SyncOption{SkipFenced(func(host string) bool { return host == held })} if mode != "default" { opts = append(opts, WithHostWrite(hostwrite.Config{Enabled: mode == "enabled", Selector: selector})) } restarts, acks := 0, 0 restart := func(host string) error { if host != sibling { t.Fatalf("restarted fenced host %s", host) } restarts++ return nil } ack := func(a ConfigAck) { if a.Host != sibling { t.Fatalf("acknowledged fenced host %s", a.Host) } acks++ } var err error if api == "legacy" { err = SyncExec(t.Context(), reg, store, nil, dir, restart, "", ack, opts...) } else { callbacks := Callbacks{Restart: restart, Ack: ack, SelectedRestart: func(_ context.Context, s hostwrite.Selection) error { return restart(s.Host) }, SelectedAck: func(_ context.Context, s hostwrite.Selection, a ConfigAck) error { if s.Host != a.Host { t.Fatal("ack selection lost") } ack(a) return nil }, } err = SyncExecWithCallbacks(t.Context(), reg, store, nil, dir, "", callbacks, opts...) } wantCalls := 1 if api == "legacy" && mode == "enabled" { if !errors.Is(err, hostwrite.ErrUnavailable) { t.Fatalf("legacy callback refusal = %v", err) } wantCalls = 0 } else if err != nil { t.Fatal(err) } if restarts != wantCalls || acks != wantCalls { t.Fatalf("restarts=%d acks=%d, want %d each", restarts, acks, wantCalls) } for _, path := range paths { if got, err := os.ReadFile(path); err != nil || string(got) != "KEEP=1\n" { t.Fatalf("fenced file %s = %q, %v", path, got, err) } } if got, err := os.ReadFile(filepath.Join(dir, sibling+configEnvSuffix)); err != nil || string(got) != "VALUE=new\n" { t.Fatalf("sibling config = %q, %v", got, err) } wantSelections := 0 if mode == "enabled" { wantSelections = 1 } if len(selector.selected) != wantSelections { t.Fatalf("selected=%v, want %d", selector.selected, wantSelections) } }) } } } } } func TestSyncExecRejectsMultipleHostWriteConfigurations(t *testing.T) { reg := testRegistry(t) defer reg.Close() opts := []SyncOption{WithHostWrite(hostwrite.Config{Enabled: true}), SkipFenced(func(string) bool { return true }), WithHostWrite(hostwrite.Config{})} if err := SyncExec(t.Context(), reg, nil, nil, t.TempDir(), nil, "", nil, opts...); !errors.Is(err, errHostWriteOptions) { t.Fatalf("multiple configs = %v", err) } if err := SyncExecWithCallbacks(t.Context(), reg, nil, nil, t.TempDir(), "", Callbacks{}, opts...); !errors.Is(err, errHostWriteOptions) { t.Fatalf("multiple configs with callbacks = %v", err) } }