| new file mode 100644 |
| index 000000000..884f083ff |
| --- /dev/null |
| +++ b/internal/host/stagerelay_test.go |
| @@ -0,0 +1,166 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func noRefusal(string) (*release.Refusal, error) { return nil, nil } |
| + |
| +func TestTheRelayAsksAgainUntilTheHeadIsInstalled(t *testing.T) { |
| + rows := map[string]*registry.Route{"a": {Host: "a", Version: "v1"}, "b": {Host: "b", Version: "v1"}} |
| + touches := 0 |
| + r := &stageRelay{pending: map[string]string{}, |
| + get: func(h string) (*registry.Route, error) { return rows[h], nil }, |
| + refused: noRefusal, |
| + touch: func() { touches++ }} |
| + if r.tick() || touches != 0 { |
| + t.Fatal("touched with nothing outstanding") |
| + } |
| + r.note("a", "v2") |
| + r.note("b", "v2") |
| + if !r.tick() || touches != 1 { |
| + t.Fatal("one touch per tick while heads are outstanding") |
| + } |
| + rows["a"].Version = "v2" |
| + delete(rows, "b") |
| + if r.tick() || touches != 1 { |
| + t.Fatal("kept asking after root installed a's head and b's route went") |
| + } |
| + r.note("a", "v3") |
| + r.get = func(string) (*registry.Route, error) { return nil, errors.New("database is locked") } |
| + if !r.tick() { |
| + t.Fatal("an unreadable row is not proof the head is installed") |
| + } |
| +} |
| + |
| +// Root's refusal record is an outcome too: once root has refused a |
| +// (host, version), asking again only reruns the refusal. |
| +func TestTheRelayStopsAtRootsRefusal(t *testing.T) { |
| + rows := map[string]*registry.Route{"a": {Host: "a", Version: "v1"}, "b": {Host: "b", Version: "v1"}} |
| + refusals := map[string]*release.Refusal{"a": {Host: "a", Version: "v2", Kind: release.RefusalWrongProgram}} |
| + touches := 0 |
| + r := &stageRelay{pending: map[string]string{}, |
| + get: func(h string) (*registry.Route, error) { return rows[h], nil }, |
| + refused: func(h string) (*release.Refusal, error) { return refusals[h], nil }, |
| + touch: func() { touches++ }} |
| + r.note("a", "v2") |
| + if r.tick() || touches != 0 { |
| + t.Fatal("touched for a head root has refused") |
| + } |
| + r.note("b", "v3") |
| + refusals["b"] = &release.Refusal{Host: "b", Version: "v2"} // an older refusal names another version |
| + if !r.tick() || touches != 1 { |
| + t.Fatal("a refusal of another version is no outcome for this head") |
| + } |
| + r.refused = func(string) (*release.Refusal, error) { return nil, errors.New("permission denied") } |
| + if !r.tick() { |
| + t.Fatal("an unreadable refusal record is not an outcome") |
| + } |
| +} |
| + |
| +// A newer head replaces the older one for the same host, so the relay asks |
| +// for the newest only and stops when that one is installed. |
| +func TestTheRelayFollowsTheNewestHead(t *testing.T) { |
| + rows := map[string]*registry.Route{"a": {Host: "a", Version: "v1"}} |
| + touches := 0 |
| + r := &stageRelay{pending: map[string]string{}, |
| + get: func(h string) (*registry.Route, error) { return rows[h], nil }, |
| + refused: noRefusal, |
| + touch: func() { touches++ }} |
| + r.note("a", "v2") |
| + r.note("a", "v3") |
| + rows["a"].Version = "v2" // root installed the superseded head |
| + if !r.tick() || touches != 1 { |
| + t.Fatal("the superseded head's install must not end the ask for the newer one") |
| + } |
| + rows["a"].Version = "v3" |
| + if r.tick() || touches != 1 { |
| + t.Fatal("kept asking after the newest head was installed") |
| + } |
| +} |
| + |
| +// The loop runs on its own goroutine, touches on its own interval and stops |
| +// when the agent's context is cancelled. |
| +func TestTheRelayLoopTicksAndStopsWithTheContext(t *testing.T) { |
| + var touches atomic.Int32 |
| + r := &stageRelay{pending: map[string]string{}, |
| + get: func(h string) (*registry.Route, error) { return ®istry.Route{Host: h, Version: "v1"}, nil }, |
| + refused: noRefusal, |
| + touch: func() { touches.Add(1) }} |
| + r.note("a", "v2") |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + done := make(chan struct{}) |
| + go func() { r.run(ctx, 5*time.Millisecond); close(done) }() |
| + deadline := time.Now().Add(5 * time.Second) |
| + for touches.Load() < 2 && time.Now().Before(deadline) { |
| + time.Sleep(time.Millisecond) |
| + } |
| + if touches.Load() < 2 { |
| + t.Fatal("the loop did not retransmit") |
| + } |
| + cancel() |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("the loop outlived its context") |
| + } |
| + n := touches.Load() |
| + time.Sleep(30 * time.Millisecond) |
| + if touches.Load() != n { |
| + t.Fatal("touched after the context was cancelled") |
| + } |
| +} |
| + |
| +// stageRequester feeds the armed relay; mintRequester shares its marker and |
| +// throttle but a proof-mint request is not a stage-blocked head, so it must |
| +// not. With no relay armed (the flag off) stageRequester is as before. |
| +func TestOnlyStageRequestsFeedTheRelay(t *testing.T) { |
| + dd := t.TempDir() |
| + stageRequester(dd)("a", "v2", nil) // unarmed: no panic, marker as before |
| + if _, err := os.Stat(filepath.Join(dd, stageRequestMarker)); err != nil { |
| + t.Fatalf("marker not written with no relay armed: %v", err) |
| + } |
| + r := &stageRelay{pending: map[string]string{}, get: func(string) (*registry.Route, error) { return nil, nil }, refused: noRefusal, touch: func() {}} |
| + armedStageRelay.Store(r) |
| + t.Cleanup(func() { armedStageRelay.Store(nil) }) |
| + mintRequester(dd)("m", "v9", nil) |
| + if len(r.pending) != 0 { |
| + t.Fatalf("a mint request reached the relay: %v", r.pending) |
| + } |
| + stageRequester(dd)("a", "v2", nil) |
| + if r.pending["a"] != "v2" { |
| + t.Fatalf("a stage-blocked head did not reach the relay: %v", r.pending) |
| + } |
| +} |
| + |
| +// armStageRelay reads root's real records: the registry row and the refusal |
| +// file in the data dir. |
| +func TestArmStageRelayReadsRootsRecords(t *testing.T) { |
| + reg, dd := testRegistry(t), t.TempDir() |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", Version: "v1"}); err != nil { |
| + t.Fatalf("seed a route: %v", err) |
| + } |
| + r := newStageRelay(reg, dd) |
| + r.note("a.example.com", "v2") |
| + if !r.tick() { |
| + t.Fatal("an uninstalled, unrefused head must be outstanding") |
| + } |
| + if _, err := os.Stat(filepath.Join(dd, stageRequestMarker)); err != nil { |
| + t.Fatalf("tick did not touch the marker: %v", err) |
| + } |
| + if err := release.WriteRefusal(dd, release.Refusal{Schema: 1, Host: "a.example.com", Version: "v2", Kind: release.RefusalNotProven}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r.tick() { |
| + t.Fatal("kept asking after root wrote a refusal for the head") |
| + } |
| +} |