| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | func plan15Enable(t *testing.T) { |
| 17 | t.Helper() |
| 18 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 19 | t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| 20 | } |
| 21 | func plan15Admit(o Observed, generation, region string) func(*Claim) error { |
| 22 | h := o.Claim.Holder |
| 23 | return AdmitWriter(Admission{ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, |
| 24 | ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: o.Claim.Op.Exec, |
| 25 | Mode: "cold", NewGeneration: generation, Region: region, PredecessorRegion: region, PredecessorCertified: true}) |
| 26 | } |
| 27 | func plan15Proof(t *testing.T, s release.Store, o Observed, generation, region string, want bool) { |
| 28 | t.Helper() |
| 29 | h := o.Claim.Holder |
| 30 | got, err := AdmittedOrigin(t.Context(), s, o, h.Incarnation, h.Location, region, generation) |
| 31 | if err != nil || got != want { |
| 32 | t.Fatalf("proof %s/%s=%v,%v want %v", region, generation, got, err, want) |
| 33 | } |
| 34 | } |
| 35 | func TestPlan15PruningMigrationAndScope(t *testing.T) { |
| 36 | t.Setenv("CARLOS_HOST_CLAIM", "") |
| 37 | t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") |
| 38 | s := release.NewDirStore(t.TempDir()) |
| 39 | o := servingClaim(t, t.Context(), s) |
| 40 | // A legacy claim can have more admissions than the covered retention window. |
| 41 | for i := 0; i < 12; i++ { |
| 42 | o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, fmt.Sprintf("old%d", i), "ie")) |
| 43 | o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| 44 | for i := range c.Holder.Admissions { |
| 45 | c.Holder.Admissions[i].Covered = true |
| 46 | } |
| 47 | return nil |
| 48 | }) |
| 49 | } |
| 50 | if len(o.Claim.Holder.Admissions) != 8 { |
| 51 | t.Fatal("fixture did not prune") |
| 52 | } |
| 53 | plan15Enable(t) |
| 54 | // The chain starts empty: before the first recorded admission, even a |
| 55 | // retained (and possibly injected) admission proves nothing. |
| 56 | plan15Proof(t, s, o, "old0", "ie", false) |
| 57 | plan15Proof(t, s, o, "old4", "ie", false) |
| 58 | plan15Proof(t, s, o, "old11", "ie", false) |
| 59 | o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "first", "ie")) |
| 60 | for i := 0; i < 24; i++ { |
| 61 | o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| 62 | for i := range c.Holder.Admissions { |
| 63 | c.Holder.Admissions[i].Covered = true |
| 64 | } |
| 65 | return nil |
| 66 | }) |
| 67 | o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, fmt.Sprintf("new%d", i), "ie")) |
| 68 | } |
| 69 | for _, g := range []string{"first", "new0", "new23"} { |
| 70 | plan15Proof(t, s, o, g, "ie", true) |
| 71 | } |
| 72 | plan15Proof(t, s, o, "old0", "ie", false) |
| 73 | plan15Proof(t, s, o, "old4", "ie", false) |
| 74 | plan15Proof(t, s, o, "old11", "ie", false) |
| 75 | plan15Proof(t, s, o, "never", "ie", false) |
| 76 | plan15Proof(t, s, o, "first", "uk", false) |
| 77 | h := o.Claim.Holder |
| 78 | for _, q := range []struct{ inc, loc string }{{strings.Repeat("b", 64), "instances/" + o.Host}, {h.Incarnation, "instances/" + o.Host}} { |
| 79 | got, err := AdmittedOrigin(t.Context(), s, o, q.inc, q.loc, "ie", "first") |
| 80 | if got || err != nil { |
| 81 | t.Fatalf("wrong scope %v %v", got, err) |
| 82 | } |
| 83 | } |
| 84 | // The old incarnation's history survives holder removal and the retirement cap. |
| 85 | old := *h |
| 86 | o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| 87 | c.Holder = nil |
| 88 | c.Op = Op{} |
| 89 | c.Obligations = nil |
| 90 | c.RetiredIncarnations = nil |
| 91 | return nil |
| 92 | }) |
| 93 | got, err := AdmittedOrigin(t.Context(), s, o, old.Incarnation, old.Location, "ie", "first") |
| 94 | if !got || err != nil { |
| 95 | t.Fatalf("retired origin %v %v", got, err) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | type plan15Store struct { |
| 100 | release.Store |
| 101 | failNode, failClaim, conflict, uncertain bool |
| 102 | puts, gets int |
| 103 | } |
| 104 | |
| 105 | func (s *plan15Store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 106 | s.gets++ |
| 107 | return s.Store.Get(ctx, key) |
| 108 | } |
| 109 | func (s *plan15Store) Put(ctx context.Context, key string, b []byte, tag string) (string, error) { |
| 110 | s.puts++ |
| 111 | if strings.HasPrefix(key, "control/host-origins/") && s.failNode { |
| 112 | return "", os.ErrPermission |
| 113 | } |
| 114 | if strings.HasPrefix(key, "control/hosts/") { |
| 115 | if s.failClaim { |
| 116 | return "", os.ErrPermission |
| 117 | } |
| 118 | if s.conflict { |
| 119 | return "", release.ErrCASConflict |
| 120 | } |
| 121 | if s.uncertain { |
| 122 | _, err := s.Store.Put(ctx, key, b, tag) |
| 123 | if err != nil { |
| 124 | return "", err |
| 125 | } |
| 126 | return "", os.ErrDeadlineExceeded |
| 127 | } |
| 128 | } |
| 129 | return s.Store.Put(ctx, key, b, tag) |
| 130 | } |
| 131 | func TestPlan15AtomicityAndRetry(t *testing.T) { |
| 132 | for _, fault := range []string{"node", "claim", "conflict", "uncertain"} { |
| 133 | t.Run(fault, func(t *testing.T) { |
| 134 | plan15Enable(t) |
| 135 | raw := release.NewDirStore(t.TempDir()) |
| 136 | o := servingClaim(t, t.Context(), raw) |
| 137 | s := &plan15Store{Store: raw, failNode: fault == "node", failClaim: fault == "claim", conflict: fault == "conflict", uncertain: fault == "uncertain"} |
| 138 | if _, err := Update(t.Context(), s, o, plan15Admit(o, "candidate", "ie")); err == nil { |
| 139 | t.Fatal("fault ignored") |
| 140 | } |
| 141 | fresh, err := Read(t.Context(), raw, o.Host) |
| 142 | if err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | committed := fault == "uncertain" |
| 146 | if (fresh.Claim.Holder.Generation == "candidate") != committed || (fresh.Claim.Origins != nil) != committed { |
| 147 | t.Fatal("split commit") |
| 148 | } |
| 149 | plan15Proof(t, raw, fresh, "candidate", "ie", committed) |
| 150 | if !committed { |
| 151 | fresh = updateGenerationTest(t, t.Context(), raw, fresh, plan15Admit(fresh, "candidate", "ie")) |
| 152 | plan15Proof(t, raw, fresh, "candidate", "ie", true) |
| 153 | } |
| 154 | if _, err := Update(t.Context(), raw, o, plan15Admit(o, "stale", "ie")); !errors.Is(err, ErrConflict) { |
| 155 | t.Fatalf("stale: %v", err) |
| 156 | } |
| 157 | plan15Proof(t, raw, fresh, "stale", "ie", false) |
| 158 | }) |
| 159 | } |
| 160 | } |
| 161 | func TestPlan15NoSelfAuthentication(t *testing.T) { |
| 162 | plan15Enable(t) |
| 163 | raw := release.NewDirStore(t.TempDir()) |
| 164 | o := servingClaim(t, t.Context(), raw) |
| 165 | o = updateGenerationTest(t, t.Context(), raw, o, plan15Admit(o, "real", "ie")) |
| 166 | h := o.Claim.Holder |
| 167 | fake := originNode{V: 1, Host: o.Host, Count: o.Claim.Origins.Count + 1, Previous: o.Claim.Origins.Digest, Origin: originTuple{h.Incarnation, h.Location, "ie", "forged"}} |
| 168 | body, _ := json.Marshal(fake) |
| 169 | digest := originDigest(body) |
| 170 | if _, err := raw.Put(t.Context(), originKey(o.Host, digest), body, ""); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | plan15Proof(t, raw, o, "forged", "ie", false) |
| 174 | // Even a caller who knows the ETag cannot substitute a new head in the reader. |
| 175 | tampered := o |
| 176 | clone := *o.Claim |
| 177 | tampered.Claim = &clone |
| 178 | clone.Origins = &OriginHead{digest, fake.Count} |
| 179 | if ok, err := AdmittedOrigin(t.Context(), raw, tampered, h.Incarnation, h.Location, "ie", "forged"); ok || !errors.Is(err, ErrConflict) { |
| 180 | t.Fatalf("edited observation: %v %v", ok, err) |
| 181 | } |
| 182 | for _, change := range []func(*Claim){func(c *Claim) { c.Origins = &OriginHead{digest, fake.Count} }, func(c *Claim) { c.Origins = nil }} { |
| 183 | if _, err := Update(t.Context(), raw, o, func(c *Claim) error { change(c); return nil }); !errors.Is(err, ErrWrongState) { |
| 184 | t.Fatalf("head bypass: %v", err) |
| 185 | } |
| 186 | } |
| 187 | // Only the observed admissions change; the store and ETag are current. |
| 188 | fresh, err := Read(t.Context(), raw, o.Host) |
| 189 | if err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | fresh.Claim.Holder.Admissions = append(fresh.Claim.Holder.Admissions, AdmissionRecord{Region: "ie", Generation: "invented", Mode: "cold"}) |
| 193 | if _, err := Update(t.Context(), raw, fresh, plan15Admit(fresh, "new", "ie")); !errors.Is(err, ErrConflict) { |
| 194 | t.Fatalf("forged migration: %v", err) |
| 195 | } |
| 196 | // Missing and corrupt reachable nodes are errors, never an affirmative answer. |
| 197 | key := originKey(o.Host, o.Claim.Origins.Digest) |
| 198 | b, tag, err := raw.Get(t.Context(), key) |
| 199 | if err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | if _, err = raw.Put(t.Context(), key, append(b, ' '), tag); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | if ok, err := AdmittedOrigin(t.Context(), raw, o, h.Incarnation, h.Location, "ie", "real"); ok || err == nil { |
| 206 | t.Fatal("corruption accepted") |
| 207 | } |
| 208 | if err = raw.Delete(t.Context(), key); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | if ok, err := AdmittedOrigin(t.Context(), raw, o, h.Incarnation, h.Location, "ie", "real"); ok || err == nil { |
| 212 | t.Fatal("missing node accepted") |
| 213 | } |
| 214 | } |
| 215 | func TestPlan15FlagOffAndWarm(t *testing.T) { |
| 216 | for _, flags := range [][2]string{{"", ""}, {"", "1"}, {"1", ""}} { |
| 217 | t.Run(fmt.Sprint(flags), func(t *testing.T) { |
| 218 | t.Setenv("CARLOS_HOST_CLAIM", flags[0]) |
| 219 | t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", flags[1]) |
| 220 | raw := release.NewDirStore(t.TempDir()) |
| 221 | o := servingClaim(t, t.Context(), raw) |
| 222 | s := &plan15Store{Store: raw} |
| 223 | var err error |
| 224 | o, err = Update(t.Context(), s, o, plan15Admit(o, "legacy", "ie")) |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | if o.Claim.Origins != nil || s.puts != 1 || s.gets != 0 { |
| 229 | t.Fatal("flag-off wrote provenance") |
| 230 | } |
| 231 | b, _ := json.Marshal(o.Claim) |
| 232 | if strings.Contains(string(b), "admitted_origins") { |
| 233 | t.Fatal("flag-off wire changed") |
| 234 | } |
| 235 | }) |
| 236 | } |
| 237 | plan15Enable(t) |
| 238 | raw := release.NewDirStore(t.TempDir()) |
| 239 | o := servingClaim(t, t.Context(), raw) |
| 240 | o = updateGenerationTest(t, t.Context(), raw, o, plan15Admit(o, "same", "ie")) |
| 241 | before := *o.Claim.Origins |
| 242 | o = updateGenerationTest(t, t.Context(), raw, o, AdmitWriter(Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "same", Box: o.Claim.Op.Exec, Mode: "warm", Region: "ie", PredecessorCertified: true})) |
| 243 | if !reflect.DeepEqual(before, *o.Claim.Origins) { |
| 244 | t.Fatal("warm duplicated origin") |
| 245 | } |
| 246 | plan15Proof(t, raw, o, "same", "ie", true) |
| 247 | t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") |
| 248 | if _, err := Update(t.Context(), raw, o, plan15Admit(o, "gap", "ie")); !errors.Is(err, ErrWrongState) { |
| 249 | t.Fatalf("disabled writer made gap: %v", err) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func TestPlan15LimitsAndMalformedHistory(t *testing.T) { |
| 254 | plan15Enable(t) |
| 255 | s := release.NewDirStore(t.TempDir()) |
| 256 | o := servingClaim(t, t.Context(), s) |
| 257 | for _, gen := range []string{"../escape", RootGeneration, strings.Repeat("x", 257)} { |
| 258 | if _, err := Update(t.Context(), s, o, plan15Admit(o, gen, "ie")); err == nil { |
| 259 | t.Fatalf("bad generation %q", gen) |
| 260 | } |
| 261 | } |
| 262 | o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "first", "ie")) |
| 263 | h := o.Claim.Holder |
| 264 | // A synthetic near-limit history tests refusal without writing a million nodes. |
| 265 | node := originNode{V: 1, Host: o.Host, Count: originCountLimit, Previous: strings.Repeat("a", 64), Origin: originTuple{h.Incarnation, h.Location, "ie", "first"}} |
| 266 | b, _ := json.Marshal(node) |
| 267 | head := OriginHead{originDigest(b), originCountLimit} |
| 268 | if _, err := s.Put(t.Context(), originKey(o.Host, head.Digest), b, ""); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | o.Claim.Origins = &head |
| 272 | key, _ := Key(o.Host) |
| 273 | b, _ = json.Marshal(o.Claim) |
| 274 | tag, err := s.Put(t.Context(), key, b, o.ETag) |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | o.ETag = tag |
| 279 | if _, err := Update(t.Context(), s, o, plan15Admit(o, "over-limit", "ie")); !errors.Is(err, ErrWrongState) { |
| 280 | t.Fatalf("limit: %v", err) |
| 281 | } |
| 282 | ctx, cancel := context.WithCancel(t.Context()) |
| 283 | cancel() |
| 284 | if ok, err := AdmittedOrigin(ctx, s, o, h.Incarnation, h.Location, "ie", "absent"); ok || err == nil { |
| 285 | t.Fatal("ignored cancellation") |
| 286 | } |
| 287 | node.Count-- |
| 288 | b, _ = json.Marshal(node) |
| 289 | // Same digest-shaped head with a mismatched count cannot prove membership. |
| 290 | if _, err := s.Put(t.Context(), originKey(o.Host, originDigest(b)), b, ""); err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | o.Claim.Origins = &OriginHead{originDigest(b), originCountLimit} |
| 294 | b, _ = json.Marshal(o.Claim) |
| 295 | o.ETag, err = s.Put(t.Context(), key, b, o.ETag) |
| 296 | if err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | if ok, err := AdmittedOrigin(t.Context(), s, o, h.Incarnation, h.Location, "ie", "first"); ok || err == nil { |
| 300 | t.Fatal("count mismatch accepted") |
| 301 | } |
| 302 | for _, head := range []*OriginHead{{"bad", 1}, {strings.Repeat("a", 64), 0}, {strings.Repeat("a", 64), originCountLimit + 1}} { |
| 303 | o.Claim.Origins = head |
| 304 | if err := o.Claim.Validate(); err == nil { |
| 305 | t.Fatal("bad head parsed") |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | |