carlos / platform Public

zdd-6 task 3: the swap-policy writer in the default config bundle

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3f3262f8cec122389b24ff782060e10bd92acd4a parent 8823643
2 files changed, +220 −0
  • internal/appconfig/swap.go +113 −0
  • internal/appconfig/swap_test.go +107 −0
diff --git a/internal/appconfig/swap.go b/internal/appconfig/swap.go
new file mode 100644
index 000000000..1d2e1bed2
--- /dev/null
+++ b/internal/appconfig/swap.go
@@ -0,0 +1,113 @@
+package appconfig
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// SwapPolicy is an app's swap policy (zero-downtime spec §4.1) with its
+// defaults filled in. It lives in the DEFAULT bundle beside exec_delivery,
+// which is where plan 5's boxes read it; a named environment never carries it.
+type SwapPolicy struct {
+ Swap string // SwapOverlap or SwapHold
+ Concurrency int // 1 or more
+}
+
+// PolicyOf reads m's swap fields with the defaults filled in, the way the
+// box's rollout.PolicyFromManifest does. nil is an app never configured. A
+// stored mode this binary does not know reads as hold: missing or
+// unverifiable policy never permits an overlap (spec §4.2).
+func PolicyOf(m *Manifest) SwapPolicy {
+ p := SwapPolicy{Swap: SwapOverlap, Concurrency: 1}
+ if m == nil {
+ return p
+ }
+ if m.Swap != "" && m.Swap != SwapOverlap {
+ p.Swap = SwapHold
+ }
+ if m.SwapConcurrency > 1 {
+ p.Concurrency = m.SwapConcurrency
+ }
+ return p
+}
+
+// GetSwapPolicy reads the app's policy. No config.json is the defaults, not
+// an error (Get's own convention).
+func GetSwapPolicy(ctx context.Context, s release.Store, acct, app string) (SwapPolicy, error) {
+ m, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return SwapPolicy{}, err
+ }
+ return PolicyOf(m), nil
+}
+
+// SetSwapPolicy writes the policy into the default bundle by the CAS
+// read-modify-write SetExecDeliveryAt uses, and returns what is stored. swap
+// "" and concurrency 0 leave their half alone.
+//
+// Defaults are stored as ABSENT keys, so an app set back to overlap/1 has the
+// bytes it had before. A call that changes nothing writes nothing and touches
+// nothing: setting the defaults on an app with no config.json never creates
+// one, because boxes read an existing empty bundle differently from an absent
+// one (SyncExec writes a file for the first and removes it for the second),
+// and that difference would read as a config change on every box.
+func SetSwapPolicy(ctx context.Context, s release.Store, acct, app, swap string, concurrency int) (claimResult0 SwapPolicy, claimResult1 error) {
+ claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetSwapPolicy", func(claimContext context.Context) error {
+ claimResult0, claimResult1 = claimRawSetSwapPolicy(claimContext, s, acct, app, swap, concurrency)
+ return claimResult1
+ })
+ return
+}
+
+// claimRawSetSwapPolicy is the unwrapped implementation of SetSwapPolicy,
+// called from within the WithAppMutation wrapper.
+func claimRawSetSwapPolicy(ctx context.Context, s release.Store, acct, app, swap string, concurrency int) (SwapPolicy, error) {
+ if !release.ValidAccount(acct) {
+ return SwapPolicy{}, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return SwapPolicy{}, fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidSwap(swap) {
+ return SwapPolicy{}, fmt.Errorf("config: invalid swap %q, want %q or %q", swap, SwapOverlap, SwapHold)
+ }
+ if !ValidSwapConcurrency(concurrency) {
+ return SwapPolicy{}, fmt.Errorf("config: invalid swap_concurrency %d, want 1 or more", concurrency)
+ }
+ key := Key(acct, app)
+ m, etag, _, err := getForWriteAt(ctx, s, key, app)
+ if err != nil {
+ return SwapPolicy{}, err
+ }
+ next := m
+ switch swap {
+ case SwapOverlap:
+ next.Swap = ""
+ case SwapHold:
+ next.Swap = SwapHold
+ }
+ switch {
+ case concurrency == 1:
+ next.SwapConcurrency = 0
+ case concurrency > 1:
+ next.SwapConcurrency = concurrency
+ }
+ if next.Swap == m.Swap && next.SwapConcurrency == m.SwapConcurrency {
+ return PolicyOf(&m), nil
+ }
+ next.Schema, next.App = 1, app
+ raw, err := json.MarshalIndent(next, "", " ")
+ if err != nil {
+ return SwapPolicy{}, err
+ }
+ if _, err := s.Put(ctx, key, raw, etag); err != nil {
+ return SwapPolicy{}, err
+ }
+ // A policy change is a config change for the fast loop (apply's doc
+ // comment): best-effort, since the write above has landed.
+ touchLogged(ctx, s, acct, app)
+ return PolicyOf(&next), nil
+}
diff --git a/internal/appconfig/swap_test.go b/internal/appconfig/swap_test.go
new file mode 100644
index 000000000..af7a112ea
--- /dev/null
+++ b/internal/appconfig/swap_test.go
@@ -0,0 +1,107 @@
+package appconfig
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Review Focus 1: an app that has never had config gets none from setting
+// the defaults, so no box sees a config change.
+func TestSwapPolicyDefaultsWriteNothing(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ got, err := SetSwapPolicy(ctx, s, "bab", "shop", SwapOverlap, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != (SwapPolicy{Swap: SwapOverlap, Concurrency: 1}) {
+ t.Fatalf("policy = %+v, want overlap/1", got)
+ }
+ if _, _, err := s.Get(ctx, Key("bab", "shop")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("setting the defaults created config.json: %v", err)
+ }
+ if _, _, err := s.Get(ctx, GenKey("bab", "shop")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("setting the defaults touched the config generation: %v", err)
+ }
+}
+
+func TestSwapPolicyHoldKeepsTheRestOfTheBundle(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := SetEnv(ctx, s, "bab", "shop", map[string]string{"A": "1"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetExecDelivery(ctx, s, "bab", "shop", ExecDeliveryFile); err != nil {
+ t.Fatal(err)
+ }
+ got, err := SetSwapPolicy(ctx, s, "bab", "shop", SwapHold, 3)
+ if err != nil || got != (SwapPolicy{Swap: SwapHold, Concurrency: 3}) {
+ t.Fatalf("policy = %+v, %v; want hold/3", got, err)
+ }
+ m, err := Get(ctx, s, "bab", "shop")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Swap != SwapHold || m.SwapConcurrency != 3 || m.Vars["A"].Value != "1" || m.ExecDelivery != ExecDeliveryFile {
+ t.Fatalf("manifest after set = %+v", m)
+ }
+ if _, _, err := s.Get(ctx, GenKey("bab", "shop")); err != nil {
+ t.Fatalf("a real change must touch the config generation: %v", err)
+ }
+ if _, err := SetSwapPolicy(ctx, s, "bab", "shop", SwapOverlap, 1); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, Key("bab", "shop"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(raw, []byte(`"swap`)) {
+ t.Fatalf("defaults still stored: %s", raw)
+ }
+}
+
+func TestSwapPolicyLeavesTheUnnamedHalf(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, err := SetSwapPolicy(ctx, s, "bab", "shop", SwapHold, 0); err != nil {
+ t.Fatal(err)
+ }
+ got, err := SetSwapPolicy(ctx, s, "bab", "shop", "", 2)
+ if err != nil || got != (SwapPolicy{Swap: SwapHold, Concurrency: 2}) {
+ t.Fatalf("policy = %+v, %v; want hold kept and 2 set", got, err)
+ }
+}
+
+func TestSwapPolicyRefusesBadValues(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, err := SetSwapPolicy(ctx, s, "bab", "shop", "fast", 0); err == nil {
+ t.Error("swap fast was accepted")
+ }
+ if _, err := SetSwapPolicy(ctx, s, "bab", "shop", "", -1); err == nil {
+ t.Error("concurrency -1 was accepted")
+ }
+ if _, err := SetSwapPolicy(ctx, s, "bab", "Shop!", SwapHold, 0); err == nil {
+ t.Error("an invalid app name was accepted")
+ }
+ if _, _, err := s.Get(ctx, Key("bab", "shop")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a refused call wrote config.json: %v", err)
+ }
+}
+
+func TestPolicyOfFailsTowardHold(t *testing.T) {
+ if got := PolicyOf(nil); got != (SwapPolicy{Swap: SwapOverlap, Concurrency: 1}) {
+ t.Errorf("PolicyOf(nil) = %+v", got)
+ }
+ if got := PolicyOf(&Manifest{Swap: "fast"}); got.Swap != SwapHold {
+ t.Errorf("an unknown stored mode reads as %q, want hold", got.Swap)
+ }
+ if got := PolicyOf(&Manifest{SwapConcurrency: -2}); got.Concurrency != 1 {
+ t.Errorf("a negative stored concurrency reads as %d, want 1", got.Concurrency)
+ }
+}