package release import ( "os" "path/filepath" "reflect" "testing" "time" "github.com/carlosframework/platform/internal/registry" ) func TestBuildStateKeySitsBesideTheLock(t *testing.T) { if got := BuildStateKey("instances/a.example.com"); got != "instances/a.example.com/build-state.json" { t.Fatalf("BuildStateKey = %q", got) } } func TestConsumedRoundTrip(t *testing.T) { opt := t.TempDir() if c, err := ReadConsumed(opt, "a.example.com"); c != nil || err != nil { t.Fatalf("absent = %+v, %v", c, err) } want := Consumed{Schema: 1, Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v2"}, Completed: 2, Desired: "v2", Reason: string(ReasonRequest), At: time.Now().UTC().Truncate(time.Second)} if err := WriteConsumed(opt, want); err != nil { t.Fatal(err) } got, err := ReadConsumed(opt, "a.example.com") if err != nil || got.Resolved.Target != "v2" || got.Completed != 2 || got.Reason != string(ReasonRequest) { t.Fatalf("round trip = %+v, %v", got, err) } if ConsumedPath(opt, "a.example.com") != filepath.Join(opt, "consumed", "a.example.com") { t.Fatal("the consumed file moved") } } func TestConsumedRefusesAnotherHostsFile(t *testing.T) { opt := t.TempDir() if err := WriteConsumed(opt, Consumed{Schema: 1, Host: "b.example.com"}); err != nil { t.Fatal(err) } if err := WriteFileAtomic(ConsumedPath(opt, "a.example.com"), mustRead(t, ConsumedPath(opt, "b.example.com")), 0o644); err != nil { t.Fatal(err) } if _, err := ReadConsumed(opt, "a.example.com"); err == nil { t.Fatal("a consumed file naming another host was accepted") } } func TestWatermarkIsTheHighestOfBoth(t *testing.T) { for _, c := range []struct { c *Consumed b *BuildState want uint64 }{ {nil, nil, 0}, {&Consumed{Completed: 4}, nil, 4}, {nil, &BuildState{Completed: 6}, 6}, {&Consumed{Completed: 4}, &BuildState{Completed: 6}, 6}, {&Consumed{Completed: 9}, &BuildState{Completed: 6}, 9}, } { if got := Watermark(c.c, c.b); got != c.want { t.Errorf("Watermark(%+v, %+v) = %d, want %d", c.c, c.b, got, c.want) } } } func TestBuildStateCopyColumn(t *testing.T) { c, err := ParseBuildStateCopy("") if err != nil || c.Known { t.Fatalf("an empty column is unknown: %+v %v", c, err) } in := BuildStateCopy{Known: true, Replica: true, State: &BuildState{Schema: 1, Host: "a.example.com", LastExecuted: "v1"}} out, err := ParseBuildStateCopy(in.Column()) if err != nil || !out.Known || !out.Replica || out.State.LastExecuted != "v1" { t.Fatalf("round trip = %+v %v", out, err) } } func TestNextBuildState(t *testing.T) { for _, tc := range []struct { name string cur *BuildState c *Consumed build string want BuildState }{ {"a: nil cur, nil consumed sets only LastExecuted", nil, nil, "v1", BuildState{LastExecuted: "v1"}}, {"b: a consumed resolution above the watermark is carried and completes when its target runs", &BuildState{Schema: 1, Host: "h", Completed: 2}, &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, "v3", BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 3}}, {"c: running another build carries the resolution but does not complete it", &BuildState{Schema: 1, Host: "h", Completed: 2}, &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, "v2", BuildState{Schema: 1, Host: "h", LastExecuted: "v2", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}}, {"d: a consumed resolution at or below the state's Completed is not carried", &BuildState{Schema: 1, Host: "h", Completed: 3}, &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, "v3", BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Completed: 3}}, {"e: a higher consumed Completed raises the state's and carries CompletedFailed", &BuildState{Schema: 1, Host: "h", Completed: 2}, &Consumed{Completed: 5, CompletedFailed: true}, "v1", BuildState{Schema: 1, Host: "h", LastExecuted: "v1", Completed: 5, CompletedFailed: true}}, {"f: an equal Completed with consumed CompletedFailed marks the state failed and does not reopen", &BuildState{Schema: 1, Host: "h", Completed: 4}, &Consumed{Completed: 4, CompletedFailed: true}, "v1", BuildState{Schema: 1, Host: "h", LastExecuted: "v1", Completed: 4, CompletedFailed: true}}, {"g: the state's own resolution from another box completes when its target runs", &BuildState{Schema: 1, Host: "h", Resolved: &ResolvedRequest{ID: 4, Target: "v3"}, Completed: 3}, nil, "v3", BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Resolved: &ResolvedRequest{ID: 4, Target: "v3"}, Completed: 4}}, {"h: the watermark never decreases", &BuildState{Schema: 1, Host: "h", Completed: 6}, &Consumed{Completed: 4}, "v1", BuildState{Schema: 1, Host: "h", LastExecuted: "v1", Completed: 6}}, {"i: a resolution below the consumed Completed never lowers the watermark", &BuildState{Schema: 1, Host: "h", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, &Consumed{Completed: 5, CompletedFailed: true}, "v3", BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 5, CompletedFailed: true}}, {"ii: a closed request whose target runs again stays closed and failed", &BuildState{Schema: 1, Host: "h", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 3, CompletedFailed: true}, nil, "v3", BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 3, CompletedFailed: true}}, {"j: an older resolution in this box's consumed file does not replace the state's newer one", &BuildState{Schema: 1, Host: "h", Resolved: &ResolvedRequest{ID: 5, Target: "v5"}, Completed: 3}, &Consumed{Resolved: &ResolvedRequest{ID: 4, Target: "v4"}, Completed: 3}, "v5", BuildState{Schema: 1, Host: "h", LastExecuted: "v5", Resolved: &ResolvedRequest{ID: 5, Target: "v5"}, Completed: 5}}, } { t.Run(tc.name, func(t *testing.T) { var curBefore BuildState if tc.cur != nil { curBefore = *tc.cur } got := NextBuildState(tc.cur, tc.c, tc.build) if !reflect.DeepEqual(got, tc.want) { t.Fatalf("NextBuildState = %+v (resolved %+v), want %+v (resolved %+v)", got, got.Resolved, tc.want, tc.want.Resolved) } if tc.cur != nil && !reflect.DeepEqual(*tc.cur, curBefore) { t.Fatalf("cur was mutated: %+v", *tc.cur) } }) } } func TestNextBuildStateDoesNotAliasConsumed(t *testing.T) { c := &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}} got := NextBuildState(nil, c, "v2") c.Resolved.Target = "changed" c.Resolved.ID = 99 if got.Resolved == nil || got.Resolved.Target != "v3" || got.Resolved.ID != 3 { t.Fatalf("the state aliases the consumed file: %+v", got.Resolved) } } func mustRead(t *testing.T, path string) []byte { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return b } // Ruling R-SPEC-43A-2 (Astra round 1 finding 3): a graceful move carries the // source box's consumed request record into the build state: root's // resolution when it is newer than everything the state knows, and a failed // completion; never a successful completion, and never last_executed (only a // run changes that). func TestCarryRequestCarriesTheResolutionAndAFailedCompletion(t *testing.T) { cur := &BuildState{Schema: 1, Host: "h", LastExecuted: "A", Completed: 1} c := &Consumed{Host: "h", Resolved: &ResolvedRequest{ID: 3, Target: "T"}, Completed: 2, CompletedFailed: true} got := CarryRequest(cur, c) if got.LastExecuted != "A" || got.Completed != 2 || !got.CompletedFailed || got.Resolved == nil || got.Resolved.ID != 3 || got.Resolved.Target != "T" { t.Fatalf("carried %+v; want A, completed 2 failed, {3 T}", got) } if cur.Completed != 1 || cur.Resolved != nil { t.Fatalf("the input was changed: %+v", cur) } // A successful completion is the pre-exec write's alone. if got := CarryRequest(cur, &Consumed{Host: "h", Completed: 5}); got.Completed != 1 || got.CompletedFailed { t.Fatalf("a successful completion was carried: %+v", got) } // An older resolution never replaces a newer one; a same-id failure marks // the completed request failed without reopening it. newer := &BuildState{LastExecuted: "A", Completed: 2, Resolved: &ResolvedRequest{ID: 4, Target: "U"}} if got := CarryRequest(newer, &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "T"}, Completed: 2, CompletedFailed: true}); got.Resolved.ID != 4 || got.Completed != 2 || !got.CompletedFailed { t.Fatalf("carried %+v; want {4 U} kept, R2 completed failed", got) } // No state yet: the record alone makes one, with no last_executed. if got := CarryRequest(nil, c); got.LastExecuted != "" || got.Completed != 2 || !got.CompletedFailed || got.Resolved == nil { t.Fatalf("carried onto no state: %+v", got) } } // The scenario: a sleeping manual instance last ran A. Root resolves R1 to // T, which this box holds as failed, so R1 completes failed in root's file // and nothing runs. A graceful move carries that into the build state, and // the destination never resolves R1 again (rule 2: a completed request never // re-enters), so it installs nothing new without another Update now. A box // that is lost carries nothing: the destination then resolves R1 again, the // spec's lost request (ยง4.2). func TestAFailedCompletionSurvivesAGracefulMove(t *testing.T) { u := UpdateSettings{Updates: UpdatesManual, Channel: "stable", InstallRequest: 1} route := registry.Route{Host: "h", Kind: "instance", UpdateSettings: u.Column()} state := &BuildState{Schema: 1, Host: "h", LastExecuted: "A"} src := TargetInput{Route: route, Pointer: &ChannelPointer{Version: "T"}, Settings: u, Build: BuildStateCopy{Known: true, Replica: true, State: state}, Installed: "A", Failed: func(v string) bool { return v == "T" }} dec := Target(src) if dec.Resolve == nil || dec.Fail != 1 { t.Fatalf("source decision %+v; want R1 resolved to T and completed failed", dec) } consumed := NextConsumed(src, dec) dst := func(st *BuildState) TargetDecision { return Target(TargetInput{Route: route, Pointer: &ChannelPointer{Version: "H"}, Settings: u, Build: BuildStateCopy{Known: true, Replica: true, State: st}}) } carried := CarryRequest(state, &consumed) if d := dst(&carried); d.Resolve != nil || d.Reason == ReasonRequest || d.Version != "A" { t.Fatalf("destination after a graceful move: %+v; want R1 closed, held on A", d) } if d := dst(state); d.Resolve == nil || d.Resolve.ID != 1 || d.Version != "H" { t.Fatalf("destination after a lost box: %+v; want R1 resolved again (a lost request)", d) } } // Found by the simulation (seed 164 after Astra round 1's changes): a box // the instance returns to keeps its old row, and that copy's refreshes // failed while the instance was away (ReadErr), so it still says request R1 // completed while the bucket says R5 completed failed. Root must read such a // copy as unread (R-TARGET-UNKNOWN): no request bookkeeping, so R5 is not // resolved again from a stale watermark, and a held instance is unknown // rather than recovered onto a stale last_executed. func TestTargetReadsACopyWhoseRefreshFailedAsUnread(t *testing.T) { stale := BuildStateCopy{Known: true, Replica: true, ReadErr: "s3: timeout", State: &BuildState{LastExecuted: "b1", Completed: 1, CompletedFailed: true}} for _, u := range []UpdateSettings{ {Updates: UpdatesAuto, Channel: "stable", InstallRequest: 5}, {Updates: UpdatesManual, Channel: "stable", InstallRequest: 5}, } { in := TargetInput{Route: registry.Route{Host: "h", UpdateSettings: u.Column()}, Pointer: &ChannelPointer{Version: "b6"}, Settings: u, Build: stale, Consumed: &Consumed{Host: "h", Completed: 1, CompletedFailed: true}, Installed: "b1"} d := Target(in) if d.Resolve != nil || d.Reason == ReasonRequest { t.Fatalf("%s: %+v; a stale copy's watermark resolved R5 again", u.Updates, d) } if u.Updates == UpdatesManual && (d.Reason != ReasonUnknown || d.Version != "") { t.Fatalf("manual: %+v; want unknown, nothing installed", d) } if next := NextConsumed(in, d); next.Baking != "" || (next.Resolved != nil && next.Resolved.ID == 5) { t.Fatalf("%s: consumed %+v", u.Updates, next) } } }