zdd-6 task 3: the swap-policy writer in the default config bundle
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2 files changed,
+220
−0
internal/appconfig/swap.go+113 −0internal/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) |
| + } |
| +} |