| 1 | package appconfig |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/registry" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | const ( |
| 17 | heldOp = "mv_abcdefghijklmnopqrstuvwxyz" |
| 18 | heldBox = "uk-box" |
| 19 | heldHost = "held.example.com" |
| 20 | ) |
| 21 | |
| 22 | // heldFixture writes all three config layers for bab/eleven and returns the |
| 23 | // etag the dir store reports for each object. |
| 24 | func heldFixture(t *testing.T, ctx context.Context, s release.Store) (def, env, overlay string) { |
| 25 | t.Helper() |
| 26 | if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"FROM_DEFAULT": "d", "SHARED": "default"}); err != nil { |
| 27 | t.Fatalf("SetEnv: %v", err) |
| 28 | } |
| 29 | if _, err := ApplyEnvironment(ctx, s, "bab", "eleven", "prod", map[string]Var{"SHARED": {Kind: "env", Value: "environment"}}, nil); err != nil { |
| 30 | t.Fatalf("ApplyEnvironment: %v", err) |
| 31 | } |
| 32 | if _, _, err := ApplyInstance(ctx, s, "bab", "eleven", heldHost, map[string]Var{"FROM_INSTANCE": {Kind: "env", Value: "i"}}, nil); err != nil { |
| 33 | t.Fatalf("ApplyInstance: %v", err) |
| 34 | } |
| 35 | etagOf := func(key string) string { |
| 36 | _, etag, err := s.Get(ctx, key) |
| 37 | if err != nil { |
| 38 | t.Fatalf("Get %s: %v", key, err) |
| 39 | } |
| 40 | return etag |
| 41 | } |
| 42 | return etagOf(Key("bab", "eleven")), etagOf(EnvironmentKey("bab", "eleven", "prod")), etagOf(InstanceConfigKey("bab", "eleven", heldHost)) |
| 43 | } |
| 44 | |
| 45 | func landingRow() registry.Route { |
| 46 | return registry.Route{Host: heldHost, Kind: "instance", App: "eleven", Account: "bab", Environment: "prod", |
| 47 | Status: "landing", HoldOp: heldOp, Socket: "/run/carlos/held.sock"} |
| 48 | } |
| 49 | |
| 50 | // TestLoadMergedHostVersionsReportsTheMergedObjects: the etag of every |
| 51 | // object the merge actually read comes back with the manifest, and an |
| 52 | // absent layer reports "". |
| 53 | func TestLoadMergedHostVersionsReportsTheMergedObjects(t *testing.T) { |
| 54 | ctx := context.Background() |
| 55 | s := release.NewDirStore(t.TempDir()) |
| 56 | def, env, overlay := heldFixture(t, ctx, s) |
| 57 | |
| 58 | m, v, err := LoadMergedHostVersions(ctx, s, "bab", "eleven", "prod", heldHost) |
| 59 | if err != nil { |
| 60 | t.Fatalf("LoadMergedHostVersions: %v", err) |
| 61 | } |
| 62 | if v != (MergedVersions{Default: def, Environment: env, Overlay: overlay}) { |
| 63 | t.Fatalf("versions = %+v, want default %q environment %q overlay %q", v, def, env, overlay) |
| 64 | } |
| 65 | for k, want := range map[string]string{"FROM_DEFAULT": "d", "SHARED": "environment", "FROM_INSTANCE": "i"} { |
| 66 | if got := m.Vars[k].Value; got != want { |
| 67 | t.Errorf("%s = %q, want %q", k, got, want) |
| 68 | } |
| 69 | } |
| 70 | legacy, etag, err := LoadMergedHost(ctx, s, "bab", "eleven", "prod", heldHost) |
| 71 | if err != nil || etag != overlay || len(legacy.Vars) != len(m.Vars) { |
| 72 | t.Fatalf("LoadMergedHost = %d vars, %q, %v; want the same merge and the overlay etag %q", len(legacy.Vars), etag, err, overlay) |
| 73 | } |
| 74 | |
| 75 | // Unbound, no overlay: only the default is read, the other two are "". |
| 76 | _, v2, err := LoadMergedHostVersions(ctx, s, "bab", "eleven", "", "other.example.com") |
| 77 | if err != nil { |
| 78 | t.Fatalf("LoadMergedHostVersions (unbound): %v", err) |
| 79 | } |
| 80 | if v2 != (MergedVersions{Default: def}) { |
| 81 | t.Fatalf("unbound versions = %+v, want only the default %q", v2, def) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func readAck(t *testing.T, ctx context.Context, s release.Store) ConfigAck { |
| 86 | t.Helper() |
| 87 | raw, _, err := s.Get(ctx, ConfigAckKey(heldBox, heldHost)) |
| 88 | if err != nil { |
| 89 | t.Fatalf("ack: %v", err) |
| 90 | } |
| 91 | var a ConfigAck |
| 92 | if err := json.Unmarshal(raw, &a); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | return a |
| 96 | } |
| 97 | |
| 98 | // TestRenderHeldWritesALandingRowSyncExecSkips: SyncExec leaves a landing |
| 99 | // row alone (execRouteBase), RenderHeld renders it through the same writer |
| 100 | // and acks it bound to the move: the op, the file's sha256 and the etags of |
| 101 | // the default and environment objects the dir store reports. |
| 102 | func TestRenderHeldWritesALandingRowSyncExecSkips(t *testing.T) { |
| 103 | ctx, s, reg, dd, privB64, _ := execKit(t) |
| 104 | priv, err := ParsePrivateKey(privB64, "test") |
| 105 | if err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | def, env, overlay := heldFixture(t, ctx, s) |
| 109 | rt := landingRow() |
| 110 | if err := reg.Upsert(rt); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if err := SyncExec(ctx, reg, s, priv, dd, nil, "", nil); err != nil { |
| 114 | t.Fatalf("SyncExec: %v", err) |
| 115 | } |
| 116 | path := ConfigEnvPath(dd, rt) |
| 117 | if _, err := os.Stat(path); !os.IsNotExist(err) { |
| 118 | t.Fatalf("SyncExec wrote a landing row's file: %v", err) |
| 119 | } |
| 120 | |
| 121 | got, err := RenderHeld(ctx, s, priv, dd, rt, heldOp, heldBox) |
| 122 | if err != nil { |
| 123 | t.Fatalf("RenderHeld: %v", err) |
| 124 | } |
| 125 | body, err := os.ReadFile(path) |
| 126 | if err != nil { |
| 127 | t.Fatalf("RenderHeld wrote no file: %v", err) |
| 128 | } |
| 129 | sum := sha256.Sum256(body) |
| 130 | wantSHA := hex.EncodeToString(sum[:]) |
| 131 | wantTags := map[string]string{"default": def, "environment": env} |
| 132 | if got.Path != path || got.SHA256 != wantSHA || got.OverlayETag != overlay || len(got.BundleETags) != 2 || got.BundleETags["default"] != def || got.BundleETags["environment"] != env { |
| 133 | t.Fatalf("HeldRender = %+v, want path %s sha %s bundles %v overlay %q", got, path, wantSHA, wantTags, overlay) |
| 134 | } |
| 135 | m, _, err := LoadMergedHostVersions(ctx, s, "bab", "eleven", "prod", heldHost) |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | want, err := RenderEnv(m, priv, routeBinding(rt)) |
| 140 | if err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | if string(body) != string(want) { |
| 144 | t.Fatalf("file = %q, want the three-layer render %q", body, want) |
| 145 | } |
| 146 | |
| 147 | a := readAck(t, ctx, s) |
| 148 | if a.MoveOp != heldOp || a.FileSHA256 != wantSHA || a.Box != heldBox || a.Host != heldHost || a.InstanceConfigETag != overlay || a.Schema != ConfigAckSchema || a.Shape != "socket-exec" { |
| 149 | t.Fatalf("ack = %+v", a) |
| 150 | } |
| 151 | if len(a.BundleETags) != 2 || a.BundleETags["default"] != def || a.BundleETags["environment"] != env { |
| 152 | t.Fatalf("ack bundle etags = %v, want %v", a.BundleETags, wantTags) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // TestRenderHeldAcksASecondCallWithNoChange: nothing changed, so the file |
| 157 | // is not rewritten, but the ack is: T3 reads it for this round. |
| 158 | func TestRenderHeldAcksASecondCallWithNoChange(t *testing.T) { |
| 159 | ctx, s, _, dd, privB64, _ := execKit(t) |
| 160 | priv, err := ParsePrivateKey(privB64, "test") |
| 161 | if err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | heldFixture(t, ctx, s) |
| 165 | rt := landingRow() |
| 166 | first, err := RenderHeld(ctx, s, priv, dd, rt, heldOp, heldBox) |
| 167 | if err != nil { |
| 168 | t.Fatalf("RenderHeld: %v", err) |
| 169 | } |
| 170 | if err := s.Delete(ctx, ConfigAckKey(heldBox, heldHost)); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | second, err := RenderHeld(ctx, s, priv, dd, rt, heldOp, heldBox) |
| 174 | if err != nil { |
| 175 | t.Fatalf("second RenderHeld: %v", err) |
| 176 | } |
| 177 | if second.SHA256 != first.SHA256 || second.Path != first.Path { |
| 178 | t.Fatalf("second = %+v, first = %+v", second, first) |
| 179 | } |
| 180 | if a := readAck(t, ctx, s); a.MoveOp != heldOp || a.FileSHA256 != first.SHA256 { |
| 181 | t.Fatalf("second call's ack = %+v", a) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // TestRenderHeldRefusesWhileHostClaimIsWired: ruling F10. RenderHeld writes |
| 186 | // through legacyHostWork, so with CARLOS_HOST_CLAIM on it refuses and |
| 187 | // writes nothing, neither file nor ack. Plan 2 adds the held-row selection. |
| 188 | func TestRenderHeldRefusesWhileHostClaimIsWired(t *testing.T) { |
| 189 | ctx, s, _, dd, privB64, _ := execKit(t) |
| 190 | priv, err := ParsePrivateKey(privB64, "test") |
| 191 | if err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | heldFixture(t, ctx, s) |
| 195 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 196 | rt := landingRow() |
| 197 | if _, err := RenderHeld(ctx, s, priv, dd, rt, heldOp, heldBox); !errors.Is(err, ErrHeldClaimWired) { |
| 198 | t.Fatalf("RenderHeld with the claim wired: %v, want ErrHeldClaimWired", err) |
| 199 | } |
| 200 | if _, err := os.Stat(ConfigEnvPath(dd, rt)); !os.IsNotExist(err) { |
| 201 | t.Fatalf("a file was written: %v", err) |
| 202 | } |
| 203 | if _, _, err := s.Get(ctx, ConfigAckKey(heldBox, heldHost)); !errors.Is(err, os.ErrNotExist) { |
| 204 | t.Fatalf("an ack was written: %v", err) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // TestRenderHeldRefusesWhatItDoesNotOwn: a row SyncExec owns (ready, or |
| 209 | // no status at all) would race SyncExec on one file, a row held by another |
| 210 | // operation is not this move's, and a missing op or box cannot be acked. |
| 211 | func TestRenderHeldRefusesWhatItDoesNotOwn(t *testing.T) { |
| 212 | ctx, s, _, dd, privB64, _ := execKit(t) |
| 213 | priv, err := ParsePrivateKey(privB64, "test") |
| 214 | if err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | heldFixture(t, ctx, s) |
| 218 | for name, tc := range map[string]struct { |
| 219 | mutate func(*registry.Route) |
| 220 | op, box string |
| 221 | }{ |
| 222 | "ready row": {func(r *registry.Route) { r.Status = "ready" }, heldOp, heldBox}, |
| 223 | "statusless row": {func(r *registry.Route) { r.Status = "" }, heldOp, heldBox}, |
| 224 | "held by another op": {func(r *registry.Route) { r.HoldOp = "mv_bcdefghijklmnopqrstuvwxyza" }, heldOp, heldBox}, |
| 225 | "no op": {func(*registry.Route) {}, "", heldBox}, |
| 226 | "no box": {func(*registry.Route) {}, heldOp, ""}, |
| 227 | "unit-backed": {func(r *registry.Route) { r.Backing = registry.BackingUnit }, heldOp, heldBox}, |
| 228 | "no socket": {func(r *registry.Route) { r.Socket = "" }, heldOp, heldBox}, |
| 229 | "not a bare host name": {func(r *registry.Route) { r.Host = "../x" }, heldOp, heldBox}, |
| 230 | } { |
| 231 | t.Run(name, func(t *testing.T) { |
| 232 | rt := landingRow() |
| 233 | tc.mutate(&rt) |
| 234 | if _, err := RenderHeld(ctx, s, priv, dd, rt, tc.op, tc.box); !errors.Is(err, ErrNotHeld) { |
| 235 | t.Fatalf("RenderHeld: %v, want ErrNotHeld", err) |
| 236 | } |
| 237 | if _, err := os.Stat(ConfigEnvPath(dd, landingRow())); !os.IsNotExist(err) { |
| 238 | t.Fatalf("a file was written: %v", err) |
| 239 | } |
| 240 | }) |
| 241 | } |
| 242 | } |
| 243 | |