server-computed runner online state and running version (#40)
- commit
- 924eaca15f3959f330e82bcb8c43d7be4ee0e1ba
- author
- brice · · 2026-10-16 10:49 UTC
- committer
- bnema · · 2026-10-16 10:49 UTC
- parent
- 81486c6
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown - refs
- tag: v0.16.0
13 files · +232 -7
M internal/adapters/kvstore/ci.go +31 -0
| @@ -1038,6 +1038,37 @@ func (c *CI) ListRunners(ctx context.Context) ([]domain.CIRunner, error) { | |||
| 1038 | 1038 | return out, nil | |
| 1039 | 1039 | } | |
| 1040 | 1040 | ||
| 1041 | + | // SetRunnerVersion records the version a runner runs now; an unchanged version | |
| 1042 | + | // is not written. | |
| 1043 | + | func (c *CI) SetRunnerVersion(ctx context.Context, id, version string) error { | |
| 1044 | + | for range createRunTries { | |
| 1045 | + | item, ok := c.db.Get(ctx, runnerKey(id)) | |
| 1046 | + | if !ok || id == "" || strings.Contains(id, "/") { | |
| 1047 | + | return domain.ErrNotFound | |
| 1048 | + | } | |
| 1049 | + | r, err := decodeRunner(item.Value) | |
| 1050 | + | if err != nil { | |
| 1051 | + | return err | |
| 1052 | + | } | |
| 1053 | + | if r.Version == version { | |
| 1054 | + | return nil | |
| 1055 | + | } | |
| 1056 | + | r.Version = version | |
| 1057 | + | var b kv.Batch | |
| 1058 | + | b.RequireVersion(runnerKey(id), item.Version) | |
| 1059 | + | b.Put(runnerKey(id), encodeRunner(r)) | |
| 1060 | + | _, err = c.db.Apply(ctx, &b) | |
| 1061 | + | if errors.Is(err, kv.ErrConflict) { | |
| 1062 | + | continue | |
| 1063 | + | } | |
| 1064 | + | if err != nil { | |
| 1065 | + | return fmt.Errorf("set runner %q version: %w", id, err) | |
| 1066 | + | } | |
| 1067 | + | return nil | |
| 1068 | + | } | |
| 1069 | + | return fmt.Errorf("set runner %q version: %w", id, domain.ErrConflict) | |
| 1070 | + | } | |
| 1071 | + | ||
| 1041 | 1072 | // RemoveRunner removes the runner, its name and its token. | |
| 1042 | 1073 | func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) { | |
| 1043 | 1074 | for range createRunTries { | |
| @@ -1038,6 +1038,37 @@ func (c *CI) ListRunners(ctx context.Context) ([]domain.CIRunner, error) { | |||||
| 1038 | return out, nil | 1038 | return out, nil | ||
| 1039 | } | 1039 | } | ||
| 1040 | 1040 | ||||
| 1041 | + | // SetRunnerVersion records the version a runner runs now; an unchanged version | |||
| 1042 | + | // is not written. | |||
| 1043 | + | func (c *CI) SetRunnerVersion(ctx context.Context, id, version string) error { | |||
| 1044 | + | for range createRunTries { | |||
| 1045 | + | item, ok := c.db.Get(ctx, runnerKey(id)) | |||
| 1046 | + | if !ok || id == "" || strings.Contains(id, "/") { | |||
| 1047 | + | return domain.ErrNotFound | |||
| 1048 | + | } | |||
| 1049 | + | r, err := decodeRunner(item.Value) | |||
| 1050 | + | if err != nil { | |||
| 1051 | + | return err | |||
| 1052 | + | } | |||
| 1053 | + | if r.Version == version { | |||
| 1054 | + | return nil | |||
| 1055 | + | } | |||
| 1056 | + | r.Version = version | |||
| 1057 | + | var b kv.Batch | |||
| 1058 | + | b.RequireVersion(runnerKey(id), item.Version) | |||
| 1059 | + | b.Put(runnerKey(id), encodeRunner(r)) | |||
| 1060 | + | _, err = c.db.Apply(ctx, &b) | |||
| 1061 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 1062 | + | continue | |||
| 1063 | + | } | |||
| 1064 | + | if err != nil { | |||
| 1065 | + | return fmt.Errorf("set runner %q version: %w", id, err) | |||
| 1066 | + | } | |||
| 1067 | + | return nil | |||
| 1068 | + | } | |||
| 1069 | + | return fmt.Errorf("set runner %q version: %w", id, domain.ErrConflict) | |||
| 1070 | + | } | |||
| 1071 | + | ||||
| 1041 | // RemoveRunner removes the runner, its name and its token. | 1072 | // RemoveRunner removes the runner, its name and its token. | ||
| 1042 | func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) { | 1073 | func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) { | ||
| 1043 | for range createRunTries { | 1074 | for range createRunTries { | ||
M internal/adapters/kvstore/ci_test.go +24 -0
| @@ -251,6 +251,30 @@ func TestCIRunnersCreateReplaceFindRemove(t *testing.T) { | |||
| 251 | 251 | assert.NoError(t, err) | |
| 252 | 252 | } | |
| 253 | 253 | ||
| 254 | + | func TestCISetRunnerVersion(t *testing.T) { | |
| 255 | + | ctx := context.Background() | |
| 256 | + | _, ci, _ := ciRepo(t) | |
| 257 | + | h := domain.HashTokenSecret("ermiterun_v") | |
| 258 | + | _, err := ci.CreateRunner(ctx, domain.CIRunner{ID: "r1", Name: "alpha", Version: "v1", TokenHash: h, RegisteredAt: created}, false) | |
| 259 | + | require.NoError(t, err) | |
| 260 | + | ||
| 261 | + | require.NoError(t, ci.SetRunnerVersion(ctx, "r1", "v2")) | |
| 262 | + | got, err := ci.GetRunner(ctx, "r1") | |
| 263 | + | require.NoError(t, err) | |
| 264 | + | assert.Equal(t, "v2", got.Version) | |
| 265 | + | assert.Equal(t, h, got.TokenHash, "the rest of the record is kept") | |
| 266 | + | assert.Equal(t, "alpha", got.Name) | |
| 267 | + | ||
| 268 | + | // An unchanged version is not written. | |
| 269 | + | before, ok := ci.RawVersionForTest(ctx, "r1") | |
| 270 | + | require.True(t, ok) | |
| 271 | + | require.NoError(t, ci.SetRunnerVersion(ctx, "r1", "v2")) | |
| 272 | + | after, _ := ci.RawVersionForTest(ctx, "r1") | |
| 273 | + | assert.Equal(t, before, after) | |
| 274 | + | ||
| 275 | + | assert.ErrorIs(t, ci.SetRunnerVersion(ctx, "gone", "v1"), domain.ErrNotFound) | |
| 276 | + | } | |
| 277 | + | ||
| 254 | 278 | func TestCISweepRemovesOldCompletedRunsOnly(t *testing.T) { | |
| 255 | 279 | ctx := context.Background() | |
| 256 | 280 | _, ci, n := ciRepo(t) | |
| @@ -251,6 +251,30 @@ func TestCIRunnersCreateReplaceFindRemove(t *testing.T) { | |||||
| 251 | assert.NoError(t, err) | 251 | assert.NoError(t, err) | ||
| 252 | } | 252 | } | ||
| 253 | 253 | ||||
| 254 | + | func TestCISetRunnerVersion(t *testing.T) { | |||
| 255 | + | ctx := context.Background() | |||
| 256 | + | _, ci, _ := ciRepo(t) | |||
| 257 | + | h := domain.HashTokenSecret("ermiterun_v") | |||
| 258 | + | _, err := ci.CreateRunner(ctx, domain.CIRunner{ID: "r1", Name: "alpha", Version: "v1", TokenHash: h, RegisteredAt: created}, false) | |||
| 259 | + | require.NoError(t, err) | |||
| 260 | + | ||||
| 261 | + | require.NoError(t, ci.SetRunnerVersion(ctx, "r1", "v2")) | |||
| 262 | + | got, err := ci.GetRunner(ctx, "r1") | |||
| 263 | + | require.NoError(t, err) | |||
| 264 | + | assert.Equal(t, "v2", got.Version) | |||
| 265 | + | assert.Equal(t, h, got.TokenHash, "the rest of the record is kept") | |||
| 266 | + | assert.Equal(t, "alpha", got.Name) | |||
| 267 | + | ||||
| 268 | + | // An unchanged version is not written. | |||
| 269 | + | before, ok := ci.RawVersionForTest(ctx, "r1") | |||
| 270 | + | require.True(t, ok) | |||
| 271 | + | require.NoError(t, ci.SetRunnerVersion(ctx, "r1", "v2")) | |||
| 272 | + | after, _ := ci.RawVersionForTest(ctx, "r1") | |||
| 273 | + | assert.Equal(t, before, after) | |||
| 274 | + | ||||
| 275 | + | assert.ErrorIs(t, ci.SetRunnerVersion(ctx, "gone", "v1"), domain.ErrNotFound) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 254 | func TestCISweepRemovesOldCompletedRunsOnly(t *testing.T) { | 278 | func TestCISweepRemovesOldCompletedRunsOnly(t *testing.T) { | ||
| 255 | ctx := context.Background() | 279 | ctx := context.Background() | ||
| 256 | _, ci, n := ciRepo(t) | 280 | _, ci, n := ciRepo(t) | ||
M internal/adapters/kvstore/export_test.go +9 -0
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package kvstore | |
| 4 | 4 | ||
| 5 | + | import "context" | |
| 6 | + | ||
| 5 | 7 | // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of | |
| 6 | 8 | // DeleteEnvironment and its batch, and returns the function that removes it. | |
| 7 | 9 | func SetAfterSettingsSnapshot(f func()) (restore func()) { | |
| @@ -16,3 +18,10 @@ var ( | |||
| 16 | 18 | EncodeConcurrencyRecord = encodeConcurrencyRecord | |
| 17 | 19 | EncodeConcurrencyMark = encodeConcurrencyMark | |
| 18 | 20 | ) | |
| 21 | + | ||
| 22 | + | // RawVersionForTest is the kv version of a runner record: it moves on every | |
| 23 | + | // write. | |
| 24 | + | func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) { | |
| 25 | + | item, ok := c.db.Get(ctx, runnerKey(id)) | |
| 26 | + | return item.Version, ok | |
| 27 | + | } | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | package kvstore | 3 | package kvstore | ||
| 4 | 4 | ||||
| 5 | + | import "context" | |||
| 6 | + | ||||
| 5 | // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of | 7 | // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of | ||
| 6 | // DeleteEnvironment and its batch, and returns the function that removes it. | 8 | // DeleteEnvironment and its batch, and returns the function that removes it. | ||
| 7 | func SetAfterSettingsSnapshot(f func()) (restore func()) { | 9 | func SetAfterSettingsSnapshot(f func()) (restore func()) { | ||
| @@ -16,3 +18,10 @@ var ( | |||||
| 16 | EncodeConcurrencyRecord = encodeConcurrencyRecord | 18 | EncodeConcurrencyRecord = encodeConcurrencyRecord | ||
| 17 | EncodeConcurrencyMark = encodeConcurrencyMark | 19 | EncodeConcurrencyMark = encodeConcurrencyMark | ||
| 18 | ) | 20 | ) | ||
| 21 | + | ||||
| 22 | + | // RawVersionForTest is the kv version of a runner record: it moves on every | |||
| 23 | + | // write. | |||
| 24 | + | func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) { | |||
| 25 | + | item, ok := c.db.Get(ctx, runnerKey(id)) | |||
| 26 | + | return item.Version, ok | |||
| 27 | + | } | |||
M internal/adapters/rpc/ci.go +1 -1
| @@ -95,7 +95,7 @@ func (s *runnerService) RegisterRunner(ctx context.Context, req *connect.Request | |||
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | 97 | func (s *runnerService) FetchJob(ctx context.Context, req *connect.Request[pb.FetchJobRequest]) (*connect.Response[pb.FetchJobResponse], error) { | |
| 98 | - | res, err := s.runners.FetchJob(ctx, secretFrom(ctx), ports.FetchJobParams{Labels: req.Msg.GetLabels(), PollID: req.Msg.GetPollId()}) | |
| 98 | + | res, err := s.runners.FetchJob(ctx, secretFrom(ctx), ports.FetchJobParams{Labels: req.Msg.GetLabels(), PollID: req.Msg.GetPollId(), Version: req.Msg.GetVersion()}) | |
| 99 | 99 | if err != nil { | |
| 100 | 100 | return nil, s.failed(ctx, err) | |
| 101 | 101 | } | |
| @@ -95,7 +95,7 @@ func (s *runnerService) RegisterRunner(ctx context.Context, req *connect.Request | |||||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | func (s *runnerService) FetchJob(ctx context.Context, req *connect.Request[pb.FetchJobRequest]) (*connect.Response[pb.FetchJobResponse], error) { | 97 | func (s *runnerService) FetchJob(ctx context.Context, req *connect.Request[pb.FetchJobRequest]) (*connect.Response[pb.FetchJobResponse], error) { | ||
| 98 | - | res, err := s.runners.FetchJob(ctx, secretFrom(ctx), ports.FetchJobParams{Labels: req.Msg.GetLabels(), PollID: req.Msg.GetPollId()}) | 98 | + | res, err := s.runners.FetchJob(ctx, secretFrom(ctx), ports.FetchJobParams{Labels: req.Msg.GetLabels(), PollID: req.Msg.GetPollId(), Version: req.Msg.GetVersion()}) |
| 99 | if err != nil { | 99 | if err != nil { | ||
| 100 | return nil, s.failed(ctx, err) | 100 | return nil, s.failed(ctx, err) | ||
| 101 | } | 101 | } | ||
M internal/adapters/rpc/ci_convert.go +1 -1
| @@ -208,7 +208,7 @@ func checkToPB(c domain.CICheck) *pb.Check { | |||
| 208 | 208 | func runnerToPB(r ports.RunnerView) *pb.Runner { | |
| 209 | 209 | return &pb.Runner{ | |
| 210 | 210 | Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: isolationToPB(r.Isolation), Version: r.Version, | |
| 211 | - | Arch: r.Arch, Os: r.OS, RegisteredAt: timestamp(r.RegisteredAt), LastSeen: timestamp(r.LastSeen), Busy: r.Busy, | |
| 211 | + | Arch: r.Arch, Os: r.OS, RegisteredAt: timestamp(r.RegisteredAt), LastSeen: timestamp(r.LastSeen), Busy: r.Busy, Online: r.Online, | |
| 212 | 212 | } | |
| 213 | 213 | } | |
| 214 | 214 | ||
| @@ -208,7 +208,7 @@ func checkToPB(c domain.CICheck) *pb.Check { | |||||
| 208 | func runnerToPB(r ports.RunnerView) *pb.Runner { | 208 | func runnerToPB(r ports.RunnerView) *pb.Runner { | ||
| 209 | return &pb.Runner{ | 209 | return &pb.Runner{ | ||
| 210 | Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: isolationToPB(r.Isolation), Version: r.Version, | 210 | Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: isolationToPB(r.Isolation), Version: r.Version, | ||
| 211 | - | Arch: r.Arch, Os: r.OS, RegisteredAt: timestamp(r.RegisteredAt), LastSeen: timestamp(r.LastSeen), Busy: r.Busy, | 211 | + | Arch: r.Arch, Os: r.OS, RegisteredAt: timestamp(r.RegisteredAt), LastSeen: timestamp(r.LastSeen), Busy: r.Busy, Online: r.Online, |
| 212 | } | 212 | } | ||
| 213 | } | 213 | } | ||
| 214 | 214 | ||||
M internal/app/ci_flow_test.go +32 -0
| @@ -682,3 +682,35 @@ func TestCIGithubContextOfAPush(t *testing.T) { | |||
| 682 | 682 | assert.Equal(t, publicOrigin+"/owner/proj/compare/"+first[:12]+"..."+second[:12], event["compare"]) | |
| 683 | 683 | assert.Equal(t, false, event["created"]) | |
| 684 | 684 | } | |
| 685 | + | ||
| 686 | + | // ListRunners says by the server's clock whether a runner is online, and shows | |
| 687 | + | // the version the runner reported at its last poll, not the one it registered | |
| 688 | + | // with. A poll without a version keeps the stored one. | |
| 689 | + | func TestCIRunnerListOnlineAndVersion(t *testing.T) { | |
| 690 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 50 * time.Millisecond }) | |
| 691 | + | r := c.registerRunner("alpha", "ubuntu-latest") | |
| 692 | + | list := func() *pb.Runner { | |
| 693 | + | res, err := c.root.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |
| 694 | + | require.NoError(t, err) | |
| 695 | + | require.Len(t, res.Msg.GetRunners(), 1) | |
| 696 | + | return res.Msg.GetRunners()[0] | |
| 697 | + | } | |
| 698 | + | poll := func(version string) error { | |
| 699 | + | _, err := r.api.runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{Version: version})) | |
| 700 | + | return err | |
| 701 | + | } | |
| 702 | + | got := list() | |
| 703 | + | assert.False(t, got.GetOnline(), "never seen") | |
| 704 | + | assert.Equal(t, "devel", got.GetVersion()) | |
| 705 | + | ||
| 706 | + | require.NoError(t, poll("v1.2.3")) | |
| 707 | + | got = list() | |
| 708 | + | assert.True(t, got.GetOnline()) | |
| 709 | + | assert.Equal(t, "v1.2.3", got.GetVersion(), "the running version replaces the registered one") | |
| 710 | + | ||
| 711 | + | require.NoError(t, poll("")) | |
| 712 | + | assert.Equal(t, "v1.2.3", list().GetVersion(), "an older runner sends none: unchanged") | |
| 713 | + | ||
| 714 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(poll(strings.Repeat("v", 65)))) | |
| 715 | + | assert.Equal(t, "v1.2.3", list().GetVersion()) | |
| 716 | + | } | |
| @@ -682,3 +682,35 @@ func TestCIGithubContextOfAPush(t *testing.T) { | |||||
| 682 | assert.Equal(t, publicOrigin+"/owner/proj/compare/"+first[:12]+"..."+second[:12], event["compare"]) | 682 | assert.Equal(t, publicOrigin+"/owner/proj/compare/"+first[:12]+"..."+second[:12], event["compare"]) | ||
| 683 | assert.Equal(t, false, event["created"]) | 683 | assert.Equal(t, false, event["created"]) | ||
| 684 | } | 684 | } | ||
| 685 | + | ||||
| 686 | + | // ListRunners says by the server's clock whether a runner is online, and shows | |||
| 687 | + | // the version the runner reported at its last poll, not the one it registered | |||
| 688 | + | // with. A poll without a version keeps the stored one. | |||
| 689 | + | func TestCIRunnerListOnlineAndVersion(t *testing.T) { | |||
| 690 | + | c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 50 * time.Millisecond }) | |||
| 691 | + | r := c.registerRunner("alpha", "ubuntu-latest") | |||
| 692 | + | list := func() *pb.Runner { | |||
| 693 | + | res, err := c.root.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |||
| 694 | + | require.NoError(t, err) | |||
| 695 | + | require.Len(t, res.Msg.GetRunners(), 1) | |||
| 696 | + | return res.Msg.GetRunners()[0] | |||
| 697 | + | } | |||
| 698 | + | poll := func(version string) error { | |||
| 699 | + | _, err := r.api.runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{Version: version})) | |||
| 700 | + | return err | |||
| 701 | + | } | |||
| 702 | + | got := list() | |||
| 703 | + | assert.False(t, got.GetOnline(), "never seen") | |||
| 704 | + | assert.Equal(t, "devel", got.GetVersion()) | |||
| 705 | + | ||||
| 706 | + | require.NoError(t, poll("v1.2.3")) | |||
| 707 | + | got = list() | |||
| 708 | + | assert.True(t, got.GetOnline()) | |||
| 709 | + | assert.Equal(t, "v1.2.3", got.GetVersion(), "the running version replaces the registered one") | |||
| 710 | + | ||||
| 711 | + | require.NoError(t, poll("")) | |||
| 712 | + | assert.Equal(t, "v1.2.3", list().GetVersion(), "an older runner sends none: unchanged") | |||
| 713 | + | ||||
| 714 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(poll(strings.Repeat("v", 65)))) | |||
| 715 | + | assert.Equal(t, "v1.2.3", list().GetVersion()) | |||
| 716 | + | } | |||
M internal/app/cli_config_ci_test.go +28 -0
| @@ -4,7 +4,9 @@ package app | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 9 | + | "time" | |
| 8 | 10 | ||
| 9 | 11 | "connectrpc.com/connect" | |
| 10 | 12 | "github.com/stretchr/testify/assert" | |
| @@ -35,6 +37,9 @@ func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRun | |||
| 35 | 37 | ||
| 36 | 38 | func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) { | |
| 37 | 39 | return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) { | |
| 40 | + | if f.runners != nil { | |
| 41 | + | return &ermite.ListRunnersResponse{Runners: f.runners}, nil | |
| 42 | + | } | |
| 38 | 43 | return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil | |
| 39 | 44 | }) | |
| 40 | 45 | } | |
| @@ -112,3 +117,26 @@ func TestCICompletionNeverRunsTokenCmd(t *testing.T) { | |||
| 112 | 117 | }) | |
| 113 | 118 | } | |
| 114 | 119 | } | |
| 120 | + | ||
| 121 | + | // The CLI shows what the server decided: it never compares last_seen with its | |
| 122 | + | // own clock. | |
| 123 | + | func TestRunnerListShowsTheServersVerdict(t *testing.T) { | |
| 124 | + | f := &fakeAPI{} | |
| 125 | + | f.runners = []*ermite.Runner{ | |
| 126 | + | {Id: "r1", Name: "up", Version: "v2", Online: true}, | |
| 127 | + | {Id: "r2", Name: "busy", Online: true, Busy: true}, | |
| 128 | + | {Id: "r3", Name: "down"}, | |
| 129 | + | } | |
| 130 | + | env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t), "ERMITE_TOKEN": "ermite_x"}) | |
| 131 | + | env.now = func() time.Time { return time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) } // a clock that is far off | |
| 132 | + | out := runCLIWith(t, env, "", "runner", "list").ok(t).stdout | |
| 133 | + | state := map[string]string{} | |
| 134 | + | for _, line := range strings.Split(strings.TrimSpace(out), "\n")[1:] { | |
| 135 | + | fields := strings.Fields(line) | |
| 136 | + | state[fields[1]] = fields[2] | |
| 137 | + | } | |
| 138 | + | assert.Equal(t, map[string]string{"up": "online", "busy": "busy", "down": "offline"}, state, out) | |
| 139 | + | ||
| 140 | + | js := runCLIWith(t, env, "", "runner", "list", "--json").ok(t).stdout | |
| 141 | + | assert.Contains(t, js, `"online": true`) | |
| 142 | + | } | |
| @@ -4,7 +4,9 @@ package app | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 9 | + | "time" | |||
| 8 | 10 | ||||
| 9 | "connectrpc.com/connect" | 11 | "connectrpc.com/connect" | ||
| 10 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| @@ -35,6 +37,9 @@ func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRun | |||||
| 35 | 37 | ||||
| 36 | func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) { | 38 | func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) { | ||
| 37 | return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) { | 39 | return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) { | ||
| 40 | + | if f.runners != nil { | |||
| 41 | + | return &ermite.ListRunnersResponse{Runners: f.runners}, nil | |||
| 42 | + | } | |||
| 38 | return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil | 43 | return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil | ||
| 39 | }) | 44 | }) | ||
| 40 | } | 45 | } | ||
| @@ -112,3 +117,26 @@ func TestCICompletionNeverRunsTokenCmd(t *testing.T) { | |||||
| 112 | }) | 117 | }) | ||
| 113 | } | 118 | } | ||
| 114 | } | 119 | } | ||
| 120 | + | ||||
| 121 | + | // The CLI shows what the server decided: it never compares last_seen with its | |||
| 122 | + | // own clock. | |||
| 123 | + | func TestRunnerListShowsTheServersVerdict(t *testing.T) { | |||
| 124 | + | f := &fakeAPI{} | |||
| 125 | + | f.runners = []*ermite.Runner{ | |||
| 126 | + | {Id: "r1", Name: "up", Version: "v2", Online: true}, | |||
| 127 | + | {Id: "r2", Name: "busy", Online: true, Busy: true}, | |||
| 128 | + | {Id: "r3", Name: "down"}, | |||
| 129 | + | } | |||
| 130 | + | env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t), "ERMITE_TOKEN": "ermite_x"}) | |||
| 131 | + | env.now = func() time.Time { return time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) } // a clock that is far off | |||
| 132 | + | out := runCLIWith(t, env, "", "runner", "list").ok(t).stdout | |||
| 133 | + | state := map[string]string{} | |||
| 134 | + | for _, line := range strings.Split(strings.TrimSpace(out), "\n")[1:] { | |||
| 135 | + | fields := strings.Fields(line) | |||
| 136 | + | state[fields[1]] = fields[2] | |||
| 137 | + | } | |||
| 138 | + | assert.Equal(t, map[string]string{"up": "online", "busy": "busy", "down": "offline"}, state, out) | |||
| 139 | + | ||||
| 140 | + | js := runCLIWith(t, env, "", "runner", "list", "--json").ok(t).stdout | |||
| 141 | + | assert.Contains(t, js, `"online": true`) | |||
| 142 | + | } | |||
M internal/app/cli_fake_test.go +2 -0
| @@ -34,6 +34,8 @@ type fakeAPI struct { | |||
| 34 | 34 | ermiteconnect.UnimplementedSessionServiceHandler | |
| 35 | 35 | ermiteconnect.UnimplementedRunServiceHandler | |
| 36 | 36 | ||
| 37 | + | runners []*ermite.Runner // ListRunners answer; nil means one default runner | |
| 38 | + | ||
| 37 | 39 | mu sync.Mutex | |
| 38 | 40 | calls []string // procedure names, in order | |
| 39 | 41 | reqs []any // request messages, in order | |
| @@ -34,6 +34,8 @@ type fakeAPI struct { | |||||
| 34 | ermiteconnect.UnimplementedSessionServiceHandler | 34 | ermiteconnect.UnimplementedSessionServiceHandler | ||
| 35 | ermiteconnect.UnimplementedRunServiceHandler | 35 | ermiteconnect.UnimplementedRunServiceHandler | ||
| 36 | 36 | ||||
| 37 | + | runners []*ermite.Runner // ListRunners answer; nil means one default runner | |||
| 38 | + | ||||
| 37 | mu sync.Mutex | 39 | mu sync.Mutex | ||
| 38 | calls []string // procedure names, in order | 40 | calls []string // procedure names, in order | ||
| 39 | reqs []any // request messages, in order | 41 | reqs []any // request messages, in order | ||
M internal/app/cli_runner.go +5 -4
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | 9 | "strings" | |
| 10 | - | "time" | |
| 11 | 10 | ||
| 12 | 11 | "connectrpc.com/connect" | |
| 13 | 12 | "github.com/spf13/cobra" | |
| @@ -27,12 +26,14 @@ type jsonRunner struct { | |||
| 27 | 26 | RegisteredAt *string `json:"registered_at"` | |
| 28 | 27 | LastSeen *string `json:"last_seen"` | |
| 29 | 28 | Busy bool `json:"busy"` | |
| 29 | + | Online bool `json:"online"` | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | const jsonRunnerHelp = ` | |
| 33 | 33 | JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox, | |
| 34 | 34 | container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never | |
| 35 | - | seen) and busy. A runner not seen for more than about 2 minutes is offline.` | |
| 35 | + | seen), busy and online (decided by the server: heard from within the last 2 minutes by its | |
| 36 | + | clock; a server older than the field reports false for every runner).` | |
| 36 | 37 | ||
| 37 | 38 | func newRunnerCmd(env *cliEnv) *cobra.Command { | |
| 38 | 39 | runner := &cobra.Command{ | |
| @@ -79,7 +80,7 @@ func newRunnerListCmd(env *cliEnv) *cobra.Command { | |||
| 79 | 80 | } | |
| 80 | 81 | out = append(out, jsonRunner{ | |
| 81 | 82 | ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(), | |
| 82 | - | Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(), | |
| 83 | + | Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(), Online: r.GetOnline(), | |
| 83 | 84 | }) | |
| 84 | 85 | } | |
| 85 | 86 | return writeJSON(cmd.OutOrStdout(), out) | |
| @@ -91,7 +92,7 @@ func newRunnerListCmd(env *cliEnv) *cobra.Command { | |||
| 91 | 92 | rows := make([][]string, 0, len(runners)) | |
| 92 | 93 | for _, r := range runners { | |
| 93 | 94 | state := "offline" | |
| 94 | - | if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute { | |
| 95 | + | if r.GetOnline() { | |
| 95 | 96 | state = "online" | |
| 96 | 97 | if r.GetBusy() { | |
| 97 | 98 | state = "busy" | |
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | - | "time" | |||
| 11 | 10 | ||||
| 12 | "connectrpc.com/connect" | 11 | "connectrpc.com/connect" | ||
| 13 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| @@ -27,12 +26,14 @@ type jsonRunner struct { | |||||
| 27 | RegisteredAt *string `json:"registered_at"` | 26 | RegisteredAt *string `json:"registered_at"` | ||
| 28 | LastSeen *string `json:"last_seen"` | 27 | LastSeen *string `json:"last_seen"` | ||
| 29 | Busy bool `json:"busy"` | 28 | Busy bool `json:"busy"` | ||
| 29 | + | Online bool `json:"online"` | |||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | const jsonRunnerHelp = ` | 32 | const jsonRunnerHelp = ` | ||
| 33 | JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox, | 33 | JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox, | ||
| 34 | container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never | 34 | container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never | ||
| 35 | - | seen) and busy. A runner not seen for more than about 2 minutes is offline.` | 35 | + | seen), busy and online (decided by the server: heard from within the last 2 minutes by its |
| 36 | + | clock; a server older than the field reports false for every runner).` | |||
| 36 | 37 | ||||
| 37 | func newRunnerCmd(env *cliEnv) *cobra.Command { | 38 | func newRunnerCmd(env *cliEnv) *cobra.Command { | ||
| 38 | runner := &cobra.Command{ | 39 | runner := &cobra.Command{ | ||
| @@ -79,7 +80,7 @@ func newRunnerListCmd(env *cliEnv) *cobra.Command { | |||||
| 79 | } | 80 | } | ||
| 80 | out = append(out, jsonRunner{ | 81 | out = append(out, jsonRunner{ | ||
| 81 | ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(), | 82 | ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(), | ||
| 82 | - | Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(), | 83 | + | Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(), Online: r.GetOnline(), |
| 83 | }) | 84 | }) | ||
| 84 | } | 85 | } | ||
| 85 | return writeJSON(cmd.OutOrStdout(), out) | 86 | return writeJSON(cmd.OutOrStdout(), out) | ||
| @@ -91,7 +92,7 @@ func newRunnerListCmd(env *cliEnv) *cobra.Command { | |||||
| 91 | rows := make([][]string, 0, len(runners)) | 92 | rows := make([][]string, 0, len(runners)) | ||
| 92 | for _, r := range runners { | 93 | for _, r := range runners { | ||
| 93 | state := "offline" | 94 | state := "offline" | ||
| 94 | - | if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute { | 95 | + | if r.GetOnline() { |
| 95 | state = "online" | 96 | state = "online" | ||
| 96 | if r.GetBusy() { | 97 | if r.GetBusy() { | ||
| 97 | state = "busy" | 98 | state = "busy" | ||
M internal/ports/ci.go +8 -0
| @@ -172,6 +172,10 @@ type CIStore interface { | |||
| 172 | 172 | FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) | |
| 173 | 173 | // ListRunners returns every runner ordered by name. | |
| 174 | 174 | ListRunners(ctx context.Context) ([]domain.CIRunner, error) | |
| 175 | + | // SetRunnerVersion records the version the runner runs now. It writes only | |
| 176 | + | // when the stored version differs, and returns domain.ErrNotFound for a | |
| 177 | + | // runner that is gone. | |
| 178 | + | SetRunnerVersion(ctx context.Context, id, version string) error | |
| 175 | 179 | // RemoveRunner removes the runner and its token, or returns | |
| 176 | 180 | // domain.ErrNotFound. | |
| 177 | 181 | RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) | |
| @@ -462,6 +466,8 @@ func (j RunnerJob) GoString() string { return j.String() } | |||
| 462 | 466 | type FetchJobParams struct { | |
| 463 | 467 | Labels []string | |
| 464 | 468 | PollID string | |
| 469 | + | // Version is the version of the running runner, "" from an older runner. | |
| 470 | + | Version string | |
| 465 | 471 | } | |
| 466 | 472 | ||
| 467 | 473 | // FetchJobResult is the answer of a poll. | |
| @@ -679,6 +685,8 @@ type RunnerView struct { | |||
| 679 | 685 | domain.CIRunner | |
| 680 | 686 | LastSeen time.Time | |
| 681 | 687 | Busy bool | |
| 688 | + | // Online is decided by the server's clock (see usecase.RunnerOnlineWindow). | |
| 689 | + | Online bool | |
| 682 | 690 | } | |
| 683 | 691 | ||
| 684 | 692 | // JobAnnotation is an annotation with the job it belongs to. | |
| @@ -172,6 +172,10 @@ type CIStore interface { | |||||
| 172 | FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) | 172 | FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) | ||
| 173 | // ListRunners returns every runner ordered by name. | 173 | // ListRunners returns every runner ordered by name. | ||
| 174 | ListRunners(ctx context.Context) ([]domain.CIRunner, error) | 174 | ListRunners(ctx context.Context) ([]domain.CIRunner, error) | ||
| 175 | + | // SetRunnerVersion records the version the runner runs now. It writes only | |||
| 176 | + | // when the stored version differs, and returns domain.ErrNotFound for a | |||
| 177 | + | // runner that is gone. | |||
| 178 | + | SetRunnerVersion(ctx context.Context, id, version string) error | |||
| 175 | // RemoveRunner removes the runner and its token, or returns | 179 | // RemoveRunner removes the runner and its token, or returns | ||
| 176 | // domain.ErrNotFound. | 180 | // domain.ErrNotFound. | ||
| 177 | RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) | 181 | RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) | ||
| @@ -462,6 +466,8 @@ func (j RunnerJob) GoString() string { return j.String() } | |||||
| 462 | type FetchJobParams struct { | 466 | type FetchJobParams struct { | ||
| 463 | Labels []string | 467 | Labels []string | ||
| 464 | PollID string | 468 | PollID string | ||
| 469 | + | // Version is the version of the running runner, "" from an older runner. | |||
| 470 | + | Version string | |||
| 465 | } | 471 | } | ||
| 466 | 472 | ||||
| 467 | // FetchJobResult is the answer of a poll. | 473 | // FetchJobResult is the answer of a poll. | ||
| @@ -679,6 +685,8 @@ type RunnerView struct { | |||||
| 679 | domain.CIRunner | 685 | domain.CIRunner | ||
| 680 | LastSeen time.Time | 686 | LastSeen time.Time | ||
| 681 | Busy bool | 687 | Busy bool | ||
| 688 | + | // Online is decided by the server's clock (see usecase.RunnerOnlineWindow). | |||
| 689 | + | Online bool | |||
| 682 | } | 690 | } | ||
| 683 | 691 | ||||
| 684 | // JobAnnotation is an annotation with the job it belongs to. | 692 | // JobAnnotation is an annotation with the job it belongs to. | ||
M internal/ports/mocks/CIStore.go +63 -0
| @@ -1535,6 +1535,69 @@ func (_c *CIStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context, repo d | |||
| 1535 | 1535 | return _c | |
| 1536 | 1536 | } | |
| 1537 | 1537 | ||
| 1538 | + | // SetRunnerVersion provides a mock function for the type CIStore | |
| 1539 | + | func (_mock *CIStore) SetRunnerVersion(ctx context.Context, id string, version string) error { | |
| 1540 | + | ret := _mock.Called(ctx, id, version) | |
| 1541 | + | ||
| 1542 | + | if len(ret) == 0 { | |
| 1543 | + | panic("no return value specified for SetRunnerVersion") | |
| 1544 | + | } | |
| 1545 | + | ||
| 1546 | + | var r0 error | |
| 1547 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 1548 | + | r0 = returnFunc(ctx, id, version) | |
| 1549 | + | } else { | |
| 1550 | + | r0 = ret.Error(0) | |
| 1551 | + | } | |
| 1552 | + | return r0 | |
| 1553 | + | } | |
| 1554 | + | ||
| 1555 | + | // CIStore_SetRunnerVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetRunnerVersion' | |
| 1556 | + | type CIStore_SetRunnerVersion_Call struct { | |
| 1557 | + | *mock.Call | |
| 1558 | + | } | |
| 1559 | + | ||
| 1560 | + | // SetRunnerVersion is a helper method to define mock.On call | |
| 1561 | + | // - ctx context.Context | |
| 1562 | + | // - id string | |
| 1563 | + | // - version string | |
| 1564 | + | func (_e *CIStore_Expecter) SetRunnerVersion(ctx any, id any, version any) *CIStore_SetRunnerVersion_Call { | |
| 1565 | + | return &CIStore_SetRunnerVersion_Call{Call: _e.mock.On("SetRunnerVersion", ctx, id, version)} | |
| 1566 | + | } | |
| 1567 | + | ||
| 1568 | + | func (_c *CIStore_SetRunnerVersion_Call) Run(run func(ctx context.Context, id string, version string)) *CIStore_SetRunnerVersion_Call { | |
| 1569 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1570 | + | var arg0 context.Context | |
| 1571 | + | if args[0] != nil { | |
| 1572 | + | arg0 = args[0].(context.Context) | |
| 1573 | + | } | |
| 1574 | + | var arg1 string | |
| 1575 | + | if args[1] != nil { | |
| 1576 | + | arg1 = args[1].(string) | |
| 1577 | + | } | |
| 1578 | + | var arg2 string | |
| 1579 | + | if args[2] != nil { | |
| 1580 | + | arg2 = args[2].(string) | |
| 1581 | + | } | |
| 1582 | + | run( | |
| 1583 | + | arg0, | |
| 1584 | + | arg1, | |
| 1585 | + | arg2, | |
| 1586 | + | ) | |
| 1587 | + | }) | |
| 1588 | + | return _c | |
| 1589 | + | } | |
| 1590 | + | ||
| 1591 | + | func (_c *CIStore_SetRunnerVersion_Call) Return(err error) *CIStore_SetRunnerVersion_Call { | |
| 1592 | + | _c.Call.Return(err) | |
| 1593 | + | return _c | |
| 1594 | + | } | |
| 1595 | + | ||
| 1596 | + | func (_c *CIStore_SetRunnerVersion_Call) RunAndReturn(run func(ctx context.Context, id string, version string) error) *CIStore_SetRunnerVersion_Call { | |
| 1597 | + | _c.Call.Return(run) | |
| 1598 | + | return _c | |
| 1599 | + | } | |
| 1600 | + | ||
| 1538 | 1601 | // Sweep provides a mock function for the type CIStore | |
| 1539 | 1602 | func (_mock *CIStore) Sweep(ctx context.Context, before time.Time) ([]int64, error) { | |
| 1540 | 1603 | ret := _mock.Called(ctx, before) | |
| @@ -1535,6 +1535,69 @@ func (_c *CIStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context, repo d | |||||
| 1535 | return _c | 1535 | return _c | ||
| 1536 | } | 1536 | } | ||
| 1537 | 1537 | ||||
| 1538 | + | // SetRunnerVersion provides a mock function for the type CIStore | |||
| 1539 | + | func (_mock *CIStore) SetRunnerVersion(ctx context.Context, id string, version string) error { | |||
| 1540 | + | ret := _mock.Called(ctx, id, version) | |||
| 1541 | + | ||||
| 1542 | + | if len(ret) == 0 { | |||
| 1543 | + | panic("no return value specified for SetRunnerVersion") | |||
| 1544 | + | } | |||
| 1545 | + | ||||
| 1546 | + | var r0 error | |||
| 1547 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |||
| 1548 | + | r0 = returnFunc(ctx, id, version) | |||
| 1549 | + | } else { | |||
| 1550 | + | r0 = ret.Error(0) | |||
| 1551 | + | } | |||
| 1552 | + | return r0 | |||
| 1553 | + | } | |||
| 1554 | + | ||||
| 1555 | + | // CIStore_SetRunnerVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetRunnerVersion' | |||
| 1556 | + | type CIStore_SetRunnerVersion_Call struct { | |||
| 1557 | + | *mock.Call | |||
| 1558 | + | } | |||
| 1559 | + | ||||
| 1560 | + | // SetRunnerVersion is a helper method to define mock.On call | |||
| 1561 | + | // - ctx context.Context | |||
| 1562 | + | // - id string | |||
| 1563 | + | // - version string | |||
| 1564 | + | func (_e *CIStore_Expecter) SetRunnerVersion(ctx any, id any, version any) *CIStore_SetRunnerVersion_Call { | |||
| 1565 | + | return &CIStore_SetRunnerVersion_Call{Call: _e.mock.On("SetRunnerVersion", ctx, id, version)} | |||
| 1566 | + | } | |||
| 1567 | + | ||||
| 1568 | + | func (_c *CIStore_SetRunnerVersion_Call) Run(run func(ctx context.Context, id string, version string)) *CIStore_SetRunnerVersion_Call { | |||
| 1569 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1570 | + | var arg0 context.Context | |||
| 1571 | + | if args[0] != nil { | |||
| 1572 | + | arg0 = args[0].(context.Context) | |||
| 1573 | + | } | |||
| 1574 | + | var arg1 string | |||
| 1575 | + | if args[1] != nil { | |||
| 1576 | + | arg1 = args[1].(string) | |||
| 1577 | + | } | |||
| 1578 | + | var arg2 string | |||
| 1579 | + | if args[2] != nil { | |||
| 1580 | + | arg2 = args[2].(string) | |||
| 1581 | + | } | |||
| 1582 | + | run( | |||
| 1583 | + | arg0, | |||
| 1584 | + | arg1, | |||
| 1585 | + | arg2, | |||
| 1586 | + | ) | |||
| 1587 | + | }) | |||
| 1588 | + | return _c | |||
| 1589 | + | } | |||
| 1590 | + | ||||
| 1591 | + | func (_c *CIStore_SetRunnerVersion_Call) Return(err error) *CIStore_SetRunnerVersion_Call { | |||
| 1592 | + | _c.Call.Return(err) | |||
| 1593 | + | return _c | |||
| 1594 | + | } | |||
| 1595 | + | ||||
| 1596 | + | func (_c *CIStore_SetRunnerVersion_Call) RunAndReturn(run func(ctx context.Context, id string, version string) error) *CIStore_SetRunnerVersion_Call { | |||
| 1597 | + | _c.Call.Return(run) | |||
| 1598 | + | return _c | |||
| 1599 | + | } | |||
| 1600 | + | ||||
| 1538 | // Sweep provides a mock function for the type CIStore | 1601 | // Sweep provides a mock function for the type CIStore | ||
| 1539 | func (_mock *CIStore) Sweep(ctx context.Context, before time.Time) ([]int64, error) { | 1602 | func (_mock *CIStore) Sweep(ctx context.Context, before time.Time) ([]int64, error) { | ||
| 1540 | ret := _mock.Called(ctx, before) | 1603 | ret := _mock.Called(ctx, before) | ||
M internal/usecase/ci_runner.go +26 -0
| @@ -97,6 +97,28 @@ func (c *CI) runnerFor(ctx context.Context, secret string) (domain.CIRunner, err | |||
| 97 | 97 | return r, err | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | + | // RunnerOnlineWindow is how long after its last sign of life (a poll or a | |
| 101 | + | // heartbeat) a runner is online, by the server's clock. | |
| 102 | + | const RunnerOnlineWindow = 2 * time.Minute | |
| 103 | + | ||
| 104 | + | func (c *CI) runnerOnline(lastSeen, now time.Time) bool { | |
| 105 | + | return !lastSeen.IsZero() && now.Sub(lastSeen) < RunnerOnlineWindow | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // noteVersion records the version a poll reports, once: the runner read for | |
| 109 | + | // this poll already holds the stored one, so an unchanged version costs no | |
| 110 | + | // write. A runner that sends none keeps its stored version. A failure to | |
| 111 | + | // record is logged and does not fail the poll. | |
| 112 | + | func (c *CI) noteVersion(ctx context.Context, r domain.CIRunner, version string) { | |
| 113 | + | if version == "" || version == r.Version { | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | if err := c.cfg.Store.SetRunnerVersion(ctx, r.ID, version); err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 117 | + | log := zerowrap.FromCtx(ctx) | |
| 118 | + | log.Warn().Err(err).Str("runner_id", r.ID).Msg("runner version not recorded") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 100 | 122 | func (c *CI) markSeen(runnerID string) { | |
| 101 | 123 | c.mu.Lock() | |
| 102 | 124 | c.seen[runnerID] = c.now() | |
| @@ -115,6 +137,9 @@ func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobP | |||
| 115 | 137 | if err != nil { | |
| 116 | 138 | return ports.FetchJobResult{}, err | |
| 117 | 139 | } | |
| 140 | + | if len(params.Version) > maxVersionLen || !utf8.ValidString(params.Version) { | |
| 141 | + | return ports.FetchJobResult{}, fmt.Errorf("%w: version is at most 64 characters", domain.ErrInvalid) | |
| 142 | + | } | |
| 118 | 143 | offered, err := offeredLabels(runner, params.Labels) | |
| 119 | 144 | if err != nil { | |
| 120 | 145 | return ports.FetchJobResult{}, err | |
| @@ -132,6 +157,7 @@ func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobP | |||
| 132 | 157 | } | |
| 133 | 158 | } | |
| 134 | 159 | c.markSeen(runner.ID) | |
| 160 | + | c.noteVersion(ctx, runner, params.Version) | |
| 135 | 161 | deadline := time.NewTimer(c.cfg.PollTimeout) | |
| 136 | 162 | defer deadline.Stop() | |
| 137 | 163 | for { | |
| @@ -97,6 +97,28 @@ func (c *CI) runnerFor(ctx context.Context, secret string) (domain.CIRunner, err | |||||
| 97 | return r, err | 97 | return r, err | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | + | // RunnerOnlineWindow is how long after its last sign of life (a poll or a | |||
| 101 | + | // heartbeat) a runner is online, by the server's clock. | |||
| 102 | + | const RunnerOnlineWindow = 2 * time.Minute | |||
| 103 | + | ||||
| 104 | + | func (c *CI) runnerOnline(lastSeen, now time.Time) bool { | |||
| 105 | + | return !lastSeen.IsZero() && now.Sub(lastSeen) < RunnerOnlineWindow | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // noteVersion records the version a poll reports, once: the runner read for | |||
| 109 | + | // this poll already holds the stored one, so an unchanged version costs no | |||
| 110 | + | // write. A runner that sends none keeps its stored version. A failure to | |||
| 111 | + | // record is logged and does not fail the poll. | |||
| 112 | + | func (c *CI) noteVersion(ctx context.Context, r domain.CIRunner, version string) { | |||
| 113 | + | if version == "" || version == r.Version { | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | if err := c.cfg.Store.SetRunnerVersion(ctx, r.ID, version); err != nil && !errors.Is(err, domain.ErrNotFound) { | |||
| 117 | + | log := zerowrap.FromCtx(ctx) | |||
| 118 | + | log.Warn().Err(err).Str("runner_id", r.ID).Msg("runner version not recorded") | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 100 | func (c *CI) markSeen(runnerID string) { | 122 | func (c *CI) markSeen(runnerID string) { | ||
| 101 | c.mu.Lock() | 123 | c.mu.Lock() | ||
| 102 | c.seen[runnerID] = c.now() | 124 | c.seen[runnerID] = c.now() | ||
| @@ -115,6 +137,9 @@ func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobP | |||||
| 115 | if err != nil { | 137 | if err != nil { | ||
| 116 | return ports.FetchJobResult{}, err | 138 | return ports.FetchJobResult{}, err | ||
| 117 | } | 139 | } | ||
| 140 | + | if len(params.Version) > maxVersionLen || !utf8.ValidString(params.Version) { | |||
| 141 | + | return ports.FetchJobResult{}, fmt.Errorf("%w: version is at most 64 characters", domain.ErrInvalid) | |||
| 142 | + | } | |||
| 118 | offered, err := offeredLabels(runner, params.Labels) | 143 | offered, err := offeredLabels(runner, params.Labels) | ||
| 119 | if err != nil { | 144 | if err != nil { | ||
| 120 | return ports.FetchJobResult{}, err | 145 | return ports.FetchJobResult{}, err | ||
| @@ -132,6 +157,7 @@ func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobP | |||||
| 132 | } | 157 | } | ||
| 133 | } | 158 | } | ||
| 134 | c.markSeen(runner.ID) | 159 | c.markSeen(runner.ID) | ||
| 160 | + | c.noteVersion(ctx, runner, params.Version) | |||
| 135 | deadline := time.NewTimer(c.cfg.PollTimeout) | 161 | deadline := time.NewTimer(c.cfg.PollTimeout) | ||
| 136 | defer deadline.Stop() | 162 | defer deadline.Stop() | ||
| 137 | for { | 163 | for { | ||
M internal/usecase/ci_runs.go +2 -1
| @@ -584,10 +584,11 @@ func (c *CI) ListRunners(ctx context.Context, p domain.Principal, size int, toke | |||
| 584 | 584 | func(r domain.CIRunner) string { | |
| 585 | 585 | return base64.RawURLEncoding.EncodeToString([]byte("rn1:" + strings.ToLower(r.Name))) | |
| 586 | 586 | }) | |
| 587 | + | now := c.now() | |
| 587 | 588 | c.mu.Lock() | |
| 588 | 589 | defer c.mu.Unlock() | |
| 589 | 590 | for _, r := range rows { | |
| 590 | - | page.Runners = append(page.Runners, ports.RunnerView{CIRunner: r, LastSeen: c.seen[r.ID], Busy: busy[r.ID]}) | |
| 591 | + | page.Runners = append(page.Runners, ports.RunnerView{CIRunner: r, LastSeen: c.seen[r.ID], Busy: busy[r.ID], Online: c.runnerOnline(c.seen[r.ID], now)}) | |
| 591 | 592 | } | |
| 592 | 593 | return page, nil | |
| 593 | 594 | } | |
| @@ -584,10 +584,11 @@ func (c *CI) ListRunners(ctx context.Context, p domain.Principal, size int, toke | |||||
| 584 | func(r domain.CIRunner) string { | 584 | func(r domain.CIRunner) string { | ||
| 585 | return base64.RawURLEncoding.EncodeToString([]byte("rn1:" + strings.ToLower(r.Name))) | 585 | return base64.RawURLEncoding.EncodeToString([]byte("rn1:" + strings.ToLower(r.Name))) | ||
| 586 | }) | 586 | }) | ||
| 587 | + | now := c.now() | |||
| 587 | c.mu.Lock() | 588 | c.mu.Lock() | ||
| 588 | defer c.mu.Unlock() | 589 | defer c.mu.Unlock() | ||
| 589 | for _, r := range rows { | 590 | for _, r := range rows { | ||
| 590 | - | page.Runners = append(page.Runners, ports.RunnerView{CIRunner: r, LastSeen: c.seen[r.ID], Busy: busy[r.ID]}) | 591 | + | page.Runners = append(page.Runners, ports.RunnerView{CIRunner: r, LastSeen: c.seen[r.ID], Busy: busy[r.ID], Online: c.runnerOnline(c.seen[r.ID], now)}) |
| 591 | } | 592 | } | ||
| 592 | return page, nil | 593 | return page, nil | ||
| 593 | } | 594 | } | ||