detached 5fbf954

feat(ci): serve RunnerService and RunService, wire CI into the server

Add the Connect adapters for RunnerService and RunService (runner and
job tokens are passed to the use case as bearer secrets, failures count
against the client address, calls on an ended job answer
failed_precondition with the JobEnded detail), the RunUpdated and
JobUpdated events on the repository topic, the ci.runs, ci.runs.filters
and ci.runners features, the wiring in the composition root (store, log
files, engine started and stopped with the server, repository deletion
cleanup, merge rule and merge trigger), and in-process end-to-end tests
with a fake runner over the real handlers.
commit
5fbf95474674f239310d4e2cbe0c95a466cf2db2
author
brice · · 2026-10-12 18:17 UTC
parent
2965018
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +1137 -13
M internal/adapters/rpc/api.go +12 -1
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
2626
2727 // Features are the optional capabilities of the contract that this server
2828 // implements, as GetVersion lists them.
29-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}
29+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}
3030
3131 // Config configures the API.
3232 type Config struct {
@@ -46,6 +46,11 @@ type Config struct {
4646 // budget of failures. Leave it unset rather than assigning a typed nil
4747 // pointer (a nil *T in the interface is not nil, and would be called).
4848 Limiter ports.FailureLimiter
49+ // Runner serves RunnerService and Runs serves RunService; without them the
50+ // CI services are not served. Leave them unset rather than assigning a
51+ // typed nil pointer.
52+ Runner ports.RunnerService
53+ Runs ports.RunService
4954 // Sessions are the owner's web sessions; nil serves no SessionService.
5055 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
5156 // the interface is not nil, and the service would be served and called).
@@ -81,6 +86,12 @@ func New(cfg Config) *API {
8186 if cfg.Sessions != nil {
8287 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))
8388 }
89+ if cfg.Runner != nil {
90+ mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...))
91+ }
92+ if cfg.Runs != nil {
93+ mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...))
94+ }
8495 return &API{handler: mux}
8596 }
8697
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
26 26
27 // Features are the optional capabilities of the contract that this server27 // Features are the optional capabilities of the contract that this server
28 // implements, as GetVersion lists them.28 // implements, as GetVersion lists them.
29-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}29+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}
30 30
31 // Config configures the API.31 // Config configures the API.
32 type Config struct {32 type Config struct {
@@ -46,6 +46,11 @@ type Config struct {
46 // budget of failures. Leave it unset rather than assigning a typed nil46 // budget of failures. Leave it unset rather than assigning a typed nil
47 // pointer (a nil *T in the interface is not nil, and would be called).47 // pointer (a nil *T in the interface is not nil, and would be called).
48 Limiter ports.FailureLimiter48 Limiter ports.FailureLimiter
49+ // Runner serves RunnerService and Runs serves RunService; without them the
50+ // CI services are not served. Leave them unset rather than assigning a
51+ // typed nil pointer.
52+ Runner ports.RunnerService
53+ Runs ports.RunService
49 // Sessions are the owner's web sessions; nil serves no SessionService.54 // Sessions are the owner's web sessions; nil serves no SessionService.
50 // Leave it unset rather than assigning a typed nil pointer (a nil *T in55 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
51 // the interface is not nil, and the service would be served and called).56 // the interface is not nil, and the service would be served and called).
@@ -81,6 +86,12 @@ func New(cfg Config) *API {
81 if cfg.Sessions != nil {86 if cfg.Sessions != nil {
82 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))87 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))
83 }88 }
89+ if cfg.Runner != nil {
90+ mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...))
91+ }
92+ if cfg.Runs != nil {
93+ mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...))
94+ }
84 return &API{handler: mux}95 return &API{handler: mux}
85 }96 }
86 97
A internal/adapters/rpc/ci.go +303 -0
@@ -0,0 +1,303 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "connectrpc.com/connect"
10+ "google.golang.org/protobuf/types/known/durationpb"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// secretKey carries the bearer secret of a runner or job call to the handler:
19+// those secrets are not stored tokens, the use case checks them.
20+type secretKey struct{}
21+
22+func secretFrom(ctx context.Context) string {
23+ s, _ := ctx.Value(secretKey{}).(string)
24+ return s
25+}
26+
27+// runnerSecretProcedures are the calls whose bearer secret is a runner token
28+// or a job token: the interceptor does not authenticate them as stored tokens.
29+var runnerSecretProcedures = map[string]bool{
30+ ermiteconnect.RunnerServiceFetchJobProcedure: true,
31+ ermiteconnect.RunnerServiceUpdateJobProcedure: true,
32+ ermiteconnect.RunnerServiceAppendLogProcedure: true,
33+ ermiteconnect.RunnerServiceHeartbeatProcedure: true,
34+ ermiteconnect.RunnerServiceWatchJobProcedure: true,
35+ ermiteconnect.RunnerServiceCompleteJobProcedure: true,
36+}
37+
38+// ciError maps an error of the CI use cases. A call on a job that ended gets
39+// failed_precondition with a JobEnded detail, as runner.proto says.
40+func ciError(ctx context.Context, err error) error {
41+ if ended, ok := domain.IsJobEnded(err); ok {
42+ ce := connect.NewError(connect.CodeFailedPrecondition, errors.New("the job has ended"))
43+ if d, derr := connect.NewErrorDetail(jobEndedToPB(ended)); derr == nil {
44+ ce.AddDetail(d)
45+ }
46+ return ce
47+ }
48+ if errors.Is(err, domain.ErrOutOfRange) {
49+ return connect.NewError(connect.CodeOutOfRange, err)
50+ }
51+ return connectError(ctx, err)
52+}
53+
54+// runnerService implements ermiteconnect.RunnerServiceHandler.
55+type runnerService struct {
56+ runners ports.RunnerService
57+ limiter ports.FailureLimiter
58+}
59+
60+// failed counts a call that was refused for its credential against the client
61+// address, like every other authentication failure, and maps the error.
62+func (s *runnerService) failed(ctx context.Context, err error) error {
63+ if errors.Is(err, domain.ErrUnauthenticated) && s.limiter != nil {
64+ if addr := addrFromContext(ctx); addr != "" {
65+ s.limiter.Fail(ctx, addr)
66+ }
67+ }
68+ return ciError(ctx, err)
69+}
70+
71+func (s *runnerService) RegisterRunner(ctx context.Context, req *connect.Request[pb.RegisterRunnerRequest]) (*connect.Response[pb.RegisterRunnerResponse], error) {
72+ iso, err := isolationFromPB(req.Msg.GetIsolation())
73+ if err != nil {
74+ return nil, ciError(ctx, err)
75+ }
76+ out, err := s.runners.Register(ctx, caller(ctx), ports.RegisterRunnerParams{
77+ Name: req.Msg.GetName(), Labels: req.Msg.GetLabels(), Version: req.Msg.GetVersion(), Isolation: iso,
78+ Arch: req.Msg.GetArch(), OS: req.Msg.GetOs(), Replace: req.Msg.GetReplace(),
79+ })
80+ if err != nil {
81+ return nil, ciError(ctx, err)
82+ }
83+ return connect.NewResponse(&pb.RegisterRunnerResponse{RunnerId: out.Runner.ID, RunnerToken: out.Token}), nil
84+}
85+
86+func (s *runnerService) FetchJob(ctx context.Context, req *connect.Request[pb.FetchJobRequest]) (*connect.Response[pb.FetchJobResponse], error) {
87+ res, err := s.runners.FetchJob(ctx, secretFrom(ctx), ports.FetchJobParams{Labels: req.Msg.GetLabels(), PollID: req.Msg.GetPollId()})
88+ if err != nil {
89+ return nil, s.failed(ctx, err)
90+ }
91+ out := &pb.FetchJobResponse{}
92+ if res.Job != nil {
93+ out.Job = runnerJobToPB(res.Job)
94+ } else if res.RetryAfter > 0 {
95+ out.RetryAfter = durationpb.New(res.RetryAfter)
96+ }
97+ return connect.NewResponse(out), nil
98+}
99+
100+func (s *runnerService) UpdateJob(ctx context.Context, req *connect.Request[pb.UpdateJobRequest]) (*connect.Response[pb.UpdateJobResponse], error) {
101+ steps, err := stepUpdatesFromPB(req.Msg.GetSteps())
102+ if err != nil {
103+ return nil, s.failed(ctx, err)
104+ }
105+ if err := s.runners.UpdateJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), steps); err != nil {
106+ return nil, s.failed(ctx, err)
107+ }
108+ return connect.NewResponse(&pb.UpdateJobResponse{}), nil
109+}
110+
111+func (s *runnerService) AppendLog(ctx context.Context, req *connect.Request[pb.AppendLogRequest]) (*connect.Response[pb.AppendLogResponse], error) {
112+ lines := make([]domain.CILogLine, 0, len(req.Msg.GetLines()))
113+ for _, l := range req.Msg.GetLines() {
114+ lines = append(lines, domain.CILogLine{Time: goTime(l.GetTime()), Text: l.GetText(), Continued: l.GetContinued()})
115+ }
116+ next, err := s.runners.AppendLog(ctx, secretFrom(ctx), req.Msg.GetJobId(), req.Msg.GetStep(), req.Msg.GetOffset(), lines)
117+ if err != nil {
118+ var gap *ports.ErrCILogGap
119+ if errors.As(err, &gap) {
120+ return nil, connect.NewError(connect.CodeOutOfRange, err)
121+ }
122+ return nil, s.failed(ctx, err)
123+ }
124+ return connect.NewResponse(&pb.AppendLogResponse{NextOffset: next}), nil
125+}
126+
127+func (s *runnerService) Heartbeat(ctx context.Context, req *connect.Request[pb.HeartbeatRequest]) (*connect.Response[pb.HeartbeatResponse], error) {
128+ if err := s.runners.Heartbeat(ctx, secretFrom(ctx), req.Msg.GetJobId()); err != nil {
129+ return nil, s.failed(ctx, err)
130+ }
131+ return connect.NewResponse(&pb.HeartbeatResponse{}), nil
132+}
133+
134+func (s *runnerService) WatchJob(ctx context.Context, req *connect.Request[pb.WatchJobRequest], stream *connect.ServerStream[pb.WatchJobResponse]) error {
135+ err := s.runners.WatchJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), func(sig ports.JobSignal) error {
136+ switch {
137+ case sig.Cancel != nil:
138+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Cancel{Cancel: &pb.CancelRequest{
139+ Reason: cancelReasonToPB(sig.Cancel.Reason), Message: sig.Cancel.Message, RequestedAt: timestamp(sig.Cancel.RequestedAt),
140+ }}})
141+ case sig.Ended != nil:
142+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Ended{Ended: jobEndedToPB(sig.Ended)}})
143+ }
144+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Keepalive{Keepalive: &pb.Keepalive{}}})
145+ })
146+ return s.failed(ctx, err)
147+}
148+
149+func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb.CompleteJobRequest]) (*connect.Response[pb.CompleteJobResponse], error) {
150+ concl, err := conclusionFromPB(req.Msg.GetConclusion())
151+ if err != nil {
152+ return nil, s.failed(ctx, err)
153+ }
154+ steps, err := stepUpdatesFromPB(req.Msg.GetSteps())
155+ if err != nil {
156+ return nil, s.failed(ctx, err)
157+ }
158+ res, err := s.runners.CompleteJob(ctx, secretFrom(ctx), ports.CompleteJobParams{
159+ JobID: req.Msg.GetJobId(), Conclusion: concl, Outputs: req.Msg.GetOutputs(),
160+ RunnerShutdown: req.Msg.GetRunnerShutdown(), Steps: steps,
161+ })
162+ if err != nil {
163+ return nil, s.failed(ctx, err)
164+ }
165+ return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil
166+}
167+
168+// runService implements ermiteconnect.RunServiceHandler.
169+type runService struct{ runs ports.RunService }
170+
171+func repoOf(s string) (domain.RepoName, error) { return domain.ParseRepoName(s) }
172+
173+func (s *runService) ListRuns(ctx context.Context, req *connect.Request[pb.ListRunsRequest]) (*connect.Response[pb.ListRunsResponse], error) {
174+ name, err := repoOf(req.Msg.GetRepo())
175+ if err != nil {
176+ return nil, ciError(ctx, err)
177+ }
178+ if _, err := statusFromPB(req.Msg.GetStatus()); err != nil {
179+ return nil, ciError(ctx, err)
180+ }
181+ page, err := s.runs.ListRuns(ctx, caller(ctx), name, ports.ListRunsParams{
182+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
183+ Workflow: req.Msg.GetWorkflow(), Branch: req.Msg.GetBranch(), Status: statusOrZero(req.Msg.GetStatus()),
184+ })
185+ if err != nil {
186+ return nil, ciError(ctx, err)
187+ }
188+ out := &pb.ListRunsResponse{NextPageToken: page.NextPageToken, Runs: make([]*pb.Run, 0, len(page.Runs))}
189+ for _, r := range page.Runs {
190+ out.Runs = append(out.Runs, runToPB(r))
191+ }
192+ return connect.NewResponse(out), nil
193+}
194+
195+func statusOrZero(s pb.CiStatus) domain.CIStatus {
196+ d, _ := statusFromPB(s)
197+ return d
198+}
199+
200+func (s *runService) GetRun(ctx context.Context, req *connect.Request[pb.GetRunRequest]) (*connect.Response[pb.GetRunResponse], error) {
201+ name, err := repoOf(req.Msg.GetRepo())
202+ if err != nil {
203+ return nil, ciError(ctx, err)
204+ }
205+ d, err := s.runs.GetRun(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetAttempt())
206+ if err != nil {
207+ return nil, ciError(ctx, err)
208+ }
209+ out := &pb.GetRunResponse{Run: runToPB(d.Run), LatestAttempt: d.LatestAttempt}
210+ for _, j := range d.Jobs {
211+ out.Jobs = append(out.Jobs, jobViewToPB(j))
212+ }
213+ return connect.NewResponse(out), nil
214+}
215+
216+func (s *runService) GetJob(ctx context.Context, req *connect.Request[pb.GetJobRequest]) (*connect.Response[pb.GetJobResponse], error) {
217+ name, err := repoOf(req.Msg.GetRepo())
218+ if err != nil {
219+ return nil, ciError(ctx, err)
220+ }
221+ j, err := s.runs.GetJob(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt())
222+ if err != nil {
223+ return nil, ciError(ctx, err)
224+ }
225+ return connect.NewResponse(&pb.GetJobResponse{Job: jobViewToPB(j)}), nil
226+}
227+
228+func (s *runService) GetJobLog(ctx context.Context, req *connect.Request[pb.GetJobLogRequest], stream *connect.ServerStream[pb.GetJobLogResponse]) error {
229+ name, err := repoOf(req.Msg.GetRepo())
230+ if err != nil {
231+ return ciError(ctx, err)
232+ }
233+ err = s.runs.GetJobLog(ctx, caller(ctx), name, ports.GetJobLogParams{
234+ JobID: req.Msg.GetJobId(), Attempt: req.Msg.GetAttempt(), Offset: req.Msg.GetOffset(), Follow: req.Msg.GetFollow(),
235+ Client: clientFromContext(ctx),
236+ }, func(b ports.LogBatch) error {
237+ out := &pb.GetJobLogResponse{Offset: b.Offset, Lines: make([]*pb.JobLogLine, 0, len(b.Lines))}
238+ for _, l := range b.Lines {
239+ out.Lines = append(out.Lines, logLineToPB(l))
240+ }
241+ return stream.Send(out)
242+ })
243+ return ciError(ctx, err)
244+}
245+
246+func (s *runService) CancelRun(ctx context.Context, req *connect.Request[pb.CancelRunRequest]) (*connect.Response[pb.CancelRunResponse], error) {
247+ name, err := repoOf(req.Msg.GetRepo())
248+ if err != nil {
249+ return nil, ciError(ctx, err)
250+ }
251+ run, err := s.runs.CancelRun(ctx, caller(ctx), name, req.Msg.GetRunId())
252+ if err != nil {
253+ return nil, ciError(ctx, err)
254+ }
255+ return connect.NewResponse(&pb.CancelRunResponse{Run: runToPB(run)}), nil
256+}
257+
258+func (s *runService) RerunRun(ctx context.Context, req *connect.Request[pb.RerunRunRequest]) (*connect.Response[pb.RerunRunResponse], error) {
259+ name, err := repoOf(req.Msg.GetRepo())
260+ if err != nil {
261+ return nil, ciError(ctx, err)
262+ }
263+ run, err := s.runs.RerunRun(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetFailedOnly())
264+ if err != nil {
265+ return nil, ciError(ctx, err)
266+ }
267+ return connect.NewResponse(&pb.RerunRunResponse{Run: runToPB(run)}), nil
268+}
269+
270+func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) {
271+ name, err := repoOf(req.Msg.GetRepo())
272+ if err != nil {
273+ return nil, ciError(ctx, err)
274+ }
275+ res, err := s.runs.ListChecks(ctx, caller(ctx), name, req.Msg.GetSha())
276+ if err != nil {
277+ return nil, ciError(ctx, err)
278+ }
279+ out := &pb.ListChecksResponse{Truncated: res.Truncated}
280+ for _, c := range res.Checks {
281+ out.Checks = append(out.Checks, checkToPB(c.CICheck))
282+ }
283+ return connect.NewResponse(out), nil
284+}
285+
286+func (s *runService) ListRunners(ctx context.Context, req *connect.Request[pb.ListRunnersRequest]) (*connect.Response[pb.ListRunnersResponse], error) {
287+ page, err := s.runs.ListRunners(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
288+ if err != nil {
289+ return nil, ciError(ctx, err)
290+ }
291+ out := &pb.ListRunnersResponse{NextPageToken: page.NextPageToken, Runners: make([]*pb.Runner, 0, len(page.Runners))}
292+ for _, r := range page.Runners {
293+ out.Runners = append(out.Runners, runnerToPB(r))
294+ }
295+ return connect.NewResponse(out), nil
296+}
297+
298+func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.RemoveRunnerRequest]) (*connect.Response[pb.RemoveRunnerResponse], error) {
299+ if err := s.runs.RemoveRunner(ctx, caller(ctx), req.Msg.GetRunnerId()); err != nil {
300+ return nil, ciError(ctx, err)
301+ }
302+ return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil
303+}
@@ -0,0 +1,303 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "connectrpc.com/connect"
10+ "google.golang.org/protobuf/types/known/durationpb"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// secretKey carries the bearer secret of a runner or job call to the handler:
19+// those secrets are not stored tokens, the use case checks them.
20+type secretKey struct{}
21+
22+func secretFrom(ctx context.Context) string {
23+ s, _ := ctx.Value(secretKey{}).(string)
24+ return s
25+}
26+
27+// runnerSecretProcedures are the calls whose bearer secret is a runner token
28+// or a job token: the interceptor does not authenticate them as stored tokens.
29+var runnerSecretProcedures = map[string]bool{
30+ ermiteconnect.RunnerServiceFetchJobProcedure: true,
31+ ermiteconnect.RunnerServiceUpdateJobProcedure: true,
32+ ermiteconnect.RunnerServiceAppendLogProcedure: true,
33+ ermiteconnect.RunnerServiceHeartbeatProcedure: true,
34+ ermiteconnect.RunnerServiceWatchJobProcedure: true,
35+ ermiteconnect.RunnerServiceCompleteJobProcedure: true,
36+}
37+
38+// ciError maps an error of the CI use cases. A call on a job that ended gets
39+// failed_precondition with a JobEnded detail, as runner.proto says.
40+func ciError(ctx context.Context, err error) error {
41+ if ended, ok := domain.IsJobEnded(err); ok {
42+ ce := connect.NewError(connect.CodeFailedPrecondition, errors.New("the job has ended"))
43+ if d, derr := connect.NewErrorDetail(jobEndedToPB(ended)); derr == nil {
44+ ce.AddDetail(d)
45+ }
46+ return ce
47+ }
48+ if errors.Is(err, domain.ErrOutOfRange) {
49+ return connect.NewError(connect.CodeOutOfRange, err)
50+ }
51+ return connectError(ctx, err)
52+}
53+
54+// runnerService implements ermiteconnect.RunnerServiceHandler.
55+type runnerService struct {
56+ runners ports.RunnerService
57+ limiter ports.FailureLimiter
58+}
59+
60+// failed counts a call that was refused for its credential against the client
61+// address, like every other authentication failure, and maps the error.
62+func (s *runnerService) failed(ctx context.Context, err error) error {
63+ if errors.Is(err, domain.ErrUnauthenticated) && s.limiter != nil {
64+ if addr := addrFromContext(ctx); addr != "" {
65+ s.limiter.Fail(ctx, addr)
66+ }
67+ }
68+ return ciError(ctx, err)
69+}
70+
71+func (s *runnerService) RegisterRunner(ctx context.Context, req *connect.Request[pb.RegisterRunnerRequest]) (*connect.Response[pb.RegisterRunnerResponse], error) {
72+ iso, err := isolationFromPB(req.Msg.GetIsolation())
73+ if err != nil {
74+ return nil, ciError(ctx, err)
75+ }
76+ out, err := s.runners.Register(ctx, caller(ctx), ports.RegisterRunnerParams{
77+ Name: req.Msg.GetName(), Labels: req.Msg.GetLabels(), Version: req.Msg.GetVersion(), Isolation: iso,
78+ Arch: req.Msg.GetArch(), OS: req.Msg.GetOs(), Replace: req.Msg.GetReplace(),
79+ })
80+ if err != nil {
81+ return nil, ciError(ctx, err)
82+ }
83+ return connect.NewResponse(&pb.RegisterRunnerResponse{RunnerId: out.Runner.ID, RunnerToken: out.Token}), nil
84+}
85+
86+func (s *runnerService) FetchJob(ctx context.Context, req *connect.Request[pb.FetchJobRequest]) (*connect.Response[pb.FetchJobResponse], error) {
87+ res, err := s.runners.FetchJob(ctx, secretFrom(ctx), ports.FetchJobParams{Labels: req.Msg.GetLabels(), PollID: req.Msg.GetPollId()})
88+ if err != nil {
89+ return nil, s.failed(ctx, err)
90+ }
91+ out := &pb.FetchJobResponse{}
92+ if res.Job != nil {
93+ out.Job = runnerJobToPB(res.Job)
94+ } else if res.RetryAfter > 0 {
95+ out.RetryAfter = durationpb.New(res.RetryAfter)
96+ }
97+ return connect.NewResponse(out), nil
98+}
99+
100+func (s *runnerService) UpdateJob(ctx context.Context, req *connect.Request[pb.UpdateJobRequest]) (*connect.Response[pb.UpdateJobResponse], error) {
101+ steps, err := stepUpdatesFromPB(req.Msg.GetSteps())
102+ if err != nil {
103+ return nil, s.failed(ctx, err)
104+ }
105+ if err := s.runners.UpdateJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), steps); err != nil {
106+ return nil, s.failed(ctx, err)
107+ }
108+ return connect.NewResponse(&pb.UpdateJobResponse{}), nil
109+}
110+
111+func (s *runnerService) AppendLog(ctx context.Context, req *connect.Request[pb.AppendLogRequest]) (*connect.Response[pb.AppendLogResponse], error) {
112+ lines := make([]domain.CILogLine, 0, len(req.Msg.GetLines()))
113+ for _, l := range req.Msg.GetLines() {
114+ lines = append(lines, domain.CILogLine{Time: goTime(l.GetTime()), Text: l.GetText(), Continued: l.GetContinued()})
115+ }
116+ next, err := s.runners.AppendLog(ctx, secretFrom(ctx), req.Msg.GetJobId(), req.Msg.GetStep(), req.Msg.GetOffset(), lines)
117+ if err != nil {
118+ var gap *ports.ErrCILogGap
119+ if errors.As(err, &gap) {
120+ return nil, connect.NewError(connect.CodeOutOfRange, err)
121+ }
122+ return nil, s.failed(ctx, err)
123+ }
124+ return connect.NewResponse(&pb.AppendLogResponse{NextOffset: next}), nil
125+}
126+
127+func (s *runnerService) Heartbeat(ctx context.Context, req *connect.Request[pb.HeartbeatRequest]) (*connect.Response[pb.HeartbeatResponse], error) {
128+ if err := s.runners.Heartbeat(ctx, secretFrom(ctx), req.Msg.GetJobId()); err != nil {
129+ return nil, s.failed(ctx, err)
130+ }
131+ return connect.NewResponse(&pb.HeartbeatResponse{}), nil
132+}
133+
134+func (s *runnerService) WatchJob(ctx context.Context, req *connect.Request[pb.WatchJobRequest], stream *connect.ServerStream[pb.WatchJobResponse]) error {
135+ err := s.runners.WatchJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), func(sig ports.JobSignal) error {
136+ switch {
137+ case sig.Cancel != nil:
138+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Cancel{Cancel: &pb.CancelRequest{
139+ Reason: cancelReasonToPB(sig.Cancel.Reason), Message: sig.Cancel.Message, RequestedAt: timestamp(sig.Cancel.RequestedAt),
140+ }}})
141+ case sig.Ended != nil:
142+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Ended{Ended: jobEndedToPB(sig.Ended)}})
143+ }
144+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Keepalive{Keepalive: &pb.Keepalive{}}})
145+ })
146+ return s.failed(ctx, err)
147+}
148+
149+func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb.CompleteJobRequest]) (*connect.Response[pb.CompleteJobResponse], error) {
150+ concl, err := conclusionFromPB(req.Msg.GetConclusion())
151+ if err != nil {
152+ return nil, s.failed(ctx, err)
153+ }
154+ steps, err := stepUpdatesFromPB(req.Msg.GetSteps())
155+ if err != nil {
156+ return nil, s.failed(ctx, err)
157+ }
158+ res, err := s.runners.CompleteJob(ctx, secretFrom(ctx), ports.CompleteJobParams{
159+ JobID: req.Msg.GetJobId(), Conclusion: concl, Outputs: req.Msg.GetOutputs(),
160+ RunnerShutdown: req.Msg.GetRunnerShutdown(), Steps: steps,
161+ })
162+ if err != nil {
163+ return nil, s.failed(ctx, err)
164+ }
165+ return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil
166+}
167+
168+// runService implements ermiteconnect.RunServiceHandler.
169+type runService struct{ runs ports.RunService }
170+
171+func repoOf(s string) (domain.RepoName, error) { return domain.ParseRepoName(s) }
172+
173+func (s *runService) ListRuns(ctx context.Context, req *connect.Request[pb.ListRunsRequest]) (*connect.Response[pb.ListRunsResponse], error) {
174+ name, err := repoOf(req.Msg.GetRepo())
175+ if err != nil {
176+ return nil, ciError(ctx, err)
177+ }
178+ if _, err := statusFromPB(req.Msg.GetStatus()); err != nil {
179+ return nil, ciError(ctx, err)
180+ }
181+ page, err := s.runs.ListRuns(ctx, caller(ctx), name, ports.ListRunsParams{
182+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
183+ Workflow: req.Msg.GetWorkflow(), Branch: req.Msg.GetBranch(), Status: statusOrZero(req.Msg.GetStatus()),
184+ })
185+ if err != nil {
186+ return nil, ciError(ctx, err)
187+ }
188+ out := &pb.ListRunsResponse{NextPageToken: page.NextPageToken, Runs: make([]*pb.Run, 0, len(page.Runs))}
189+ for _, r := range page.Runs {
190+ out.Runs = append(out.Runs, runToPB(r))
191+ }
192+ return connect.NewResponse(out), nil
193+}
194+
195+func statusOrZero(s pb.CiStatus) domain.CIStatus {
196+ d, _ := statusFromPB(s)
197+ return d
198+}
199+
200+func (s *runService) GetRun(ctx context.Context, req *connect.Request[pb.GetRunRequest]) (*connect.Response[pb.GetRunResponse], error) {
201+ name, err := repoOf(req.Msg.GetRepo())
202+ if err != nil {
203+ return nil, ciError(ctx, err)
204+ }
205+ d, err := s.runs.GetRun(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetAttempt())
206+ if err != nil {
207+ return nil, ciError(ctx, err)
208+ }
209+ out := &pb.GetRunResponse{Run: runToPB(d.Run), LatestAttempt: d.LatestAttempt}
210+ for _, j := range d.Jobs {
211+ out.Jobs = append(out.Jobs, jobViewToPB(j))
212+ }
213+ return connect.NewResponse(out), nil
214+}
215+
216+func (s *runService) GetJob(ctx context.Context, req *connect.Request[pb.GetJobRequest]) (*connect.Response[pb.GetJobResponse], error) {
217+ name, err := repoOf(req.Msg.GetRepo())
218+ if err != nil {
219+ return nil, ciError(ctx, err)
220+ }
221+ j, err := s.runs.GetJob(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt())
222+ if err != nil {
223+ return nil, ciError(ctx, err)
224+ }
225+ return connect.NewResponse(&pb.GetJobResponse{Job: jobViewToPB(j)}), nil
226+}
227+
228+func (s *runService) GetJobLog(ctx context.Context, req *connect.Request[pb.GetJobLogRequest], stream *connect.ServerStream[pb.GetJobLogResponse]) error {
229+ name, err := repoOf(req.Msg.GetRepo())
230+ if err != nil {
231+ return ciError(ctx, err)
232+ }
233+ err = s.runs.GetJobLog(ctx, caller(ctx), name, ports.GetJobLogParams{
234+ JobID: req.Msg.GetJobId(), Attempt: req.Msg.GetAttempt(), Offset: req.Msg.GetOffset(), Follow: req.Msg.GetFollow(),
235+ Client: clientFromContext(ctx),
236+ }, func(b ports.LogBatch) error {
237+ out := &pb.GetJobLogResponse{Offset: b.Offset, Lines: make([]*pb.JobLogLine, 0, len(b.Lines))}
238+ for _, l := range b.Lines {
239+ out.Lines = append(out.Lines, logLineToPB(l))
240+ }
241+ return stream.Send(out)
242+ })
243+ return ciError(ctx, err)
244+}
245+
246+func (s *runService) CancelRun(ctx context.Context, req *connect.Request[pb.CancelRunRequest]) (*connect.Response[pb.CancelRunResponse], error) {
247+ name, err := repoOf(req.Msg.GetRepo())
248+ if err != nil {
249+ return nil, ciError(ctx, err)
250+ }
251+ run, err := s.runs.CancelRun(ctx, caller(ctx), name, req.Msg.GetRunId())
252+ if err != nil {
253+ return nil, ciError(ctx, err)
254+ }
255+ return connect.NewResponse(&pb.CancelRunResponse{Run: runToPB(run)}), nil
256+}
257+
258+func (s *runService) RerunRun(ctx context.Context, req *connect.Request[pb.RerunRunRequest]) (*connect.Response[pb.RerunRunResponse], error) {
259+ name, err := repoOf(req.Msg.GetRepo())
260+ if err != nil {
261+ return nil, ciError(ctx, err)
262+ }
263+ run, err := s.runs.RerunRun(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetFailedOnly())
264+ if err != nil {
265+ return nil, ciError(ctx, err)
266+ }
267+ return connect.NewResponse(&pb.RerunRunResponse{Run: runToPB(run)}), nil
268+}
269+
270+func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) {
271+ name, err := repoOf(req.Msg.GetRepo())
272+ if err != nil {
273+ return nil, ciError(ctx, err)
274+ }
275+ res, err := s.runs.ListChecks(ctx, caller(ctx), name, req.Msg.GetSha())
276+ if err != nil {
277+ return nil, ciError(ctx, err)
278+ }
279+ out := &pb.ListChecksResponse{Truncated: res.Truncated}
280+ for _, c := range res.Checks {
281+ out.Checks = append(out.Checks, checkToPB(c.CICheck))
282+ }
283+ return connect.NewResponse(out), nil
284+}
285+
286+func (s *runService) ListRunners(ctx context.Context, req *connect.Request[pb.ListRunnersRequest]) (*connect.Response[pb.ListRunnersResponse], error) {
287+ page, err := s.runs.ListRunners(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
288+ if err != nil {
289+ return nil, ciError(ctx, err)
290+ }
291+ out := &pb.ListRunnersResponse{NextPageToken: page.NextPageToken, Runners: make([]*pb.Runner, 0, len(page.Runners))}
292+ for _, r := range page.Runners {
293+ out.Runners = append(out.Runners, runnerToPB(r))
294+ }
295+ return connect.NewResponse(out), nil
296+}
297+
298+func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.RemoveRunnerRequest]) (*connect.Response[pb.RemoveRunnerResponse], error) {
299+ if err := s.runs.RemoveRunner(ctx, caller(ctx), req.Msg.GetRunnerId()); err != nil {
300+ return nil, ciError(ctx, err)
301+ }
302+ return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil
303+}
A internal/adapters/rpc/ci_convert.go +228 -0
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "fmt"
7+
8+ pb "bnema.dev/ermite-api/gen/ermite"
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports"
11+)
12+
13+func statusToPB(s domain.CIStatus) pb.CiStatus {
14+ switch s {
15+ case domain.CIQueued:
16+ return pb.CiStatus_CI_STATUS_QUEUED
17+ case domain.CIInProgress:
18+ return pb.CiStatus_CI_STATUS_IN_PROGRESS
19+ case domain.CICompleted:
20+ return pb.CiStatus_CI_STATUS_COMPLETED
21+ }
22+ return pb.CiStatus_CI_STATUS_UNSPECIFIED
23+}
24+
25+func statusFromPB(s pb.CiStatus) (domain.CIStatus, error) {
26+ switch s {
27+ case pb.CiStatus_CI_STATUS_UNSPECIFIED:
28+ return "", nil
29+ case pb.CiStatus_CI_STATUS_QUEUED:
30+ return domain.CIQueued, nil
31+ case pb.CiStatus_CI_STATUS_IN_PROGRESS:
32+ return domain.CIInProgress, nil
33+ case pb.CiStatus_CI_STATUS_COMPLETED:
34+ return domain.CICompleted, nil
35+ }
36+ return "", fmt.Errorf("%w: status %v", domain.ErrInvalid, s)
37+}
38+
39+var conclusionPairs = []struct {
40+ d domain.CIConclusion
41+ p pb.CiConclusion
42+}{
43+ {domain.CISuccess, pb.CiConclusion_CI_CONCLUSION_SUCCESS},
44+ {domain.CIFailure, pb.CiConclusion_CI_CONCLUSION_FAILURE},
45+ {domain.CICancelled, pb.CiConclusion_CI_CONCLUSION_CANCELLED},
46+ {domain.CISkipped, pb.CiConclusion_CI_CONCLUSION_SKIPPED},
47+ {domain.CITimedOut, pb.CiConclusion_CI_CONCLUSION_TIMED_OUT},
48+ {domain.CINeutral, pb.CiConclusion_CI_CONCLUSION_NEUTRAL},
49+ {domain.CIActionRequired, pb.CiConclusion_CI_CONCLUSION_ACTION_REQUIRED},
50+}
51+
52+func conclusionToPB(c domain.CIConclusion) pb.CiConclusion {
53+ for _, e := range conclusionPairs {
54+ if e.d == c {
55+ return e.p
56+ }
57+ }
58+ return pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED
59+}
60+
61+func conclusionFromPB(c pb.CiConclusion) (domain.CIConclusion, error) {
62+ if c == pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
63+ return domain.CINoConclusion, nil
64+ }
65+ for _, e := range conclusionPairs {
66+ if e.p == c {
67+ return e.d, nil
68+ }
69+ }
70+ return "", fmt.Errorf("%w: conclusion %v", domain.ErrInvalid, c)
71+}
72+
73+func isolationToPB(i domain.CIIsolation) pb.RunnerIsolation {
74+ switch i {
75+ case domain.CIIsolationVM:
76+ return pb.RunnerIsolation_RUNNER_ISOLATION_VM
77+ case domain.CIIsolationContainerSysbox:
78+ return pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX
79+ case domain.CIIsolationContainerPrivileged:
80+ return pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED
81+ }
82+ return pb.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED
83+}
84+
85+func isolationFromPB(i pb.RunnerIsolation) (domain.CIIsolation, error) {
86+ switch i {
87+ case pb.RunnerIsolation_RUNNER_ISOLATION_VM:
88+ return domain.CIIsolationVM, nil
89+ case pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX:
90+ return domain.CIIsolationContainerSysbox, nil
91+ case pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED:
92+ return domain.CIIsolationContainerPrivileged, nil
93+ }
94+ return "", fmt.Errorf("%w: isolation is required", domain.ErrInvalid)
95+}
96+
97+func stepStateToPB(s domain.CIStepState) pb.StepState {
98+ switch s {
99+ case domain.CIStepPending:
100+ return pb.StepState_STEP_STATE_PENDING
101+ case domain.CIStepInProgress:
102+ return pb.StepState_STEP_STATE_IN_PROGRESS
103+ case domain.CIStepCompleted:
104+ return pb.StepState_STEP_STATE_COMPLETED
105+ }
106+ return pb.StepState_STEP_STATE_UNSPECIFIED
107+}
108+
109+func stepStateFromPB(s pb.StepState) domain.CIStepState {
110+ switch s {
111+ case pb.StepState_STEP_STATE_PENDING:
112+ return domain.CIStepPending
113+ case pb.StepState_STEP_STATE_IN_PROGRESS:
114+ return domain.CIStepInProgress
115+ case pb.StepState_STEP_STATE_COMPLETED:
116+ return domain.CIStepCompleted
117+ }
118+ return ""
119+}
120+
121+func cancelReasonToPB(r domain.CICancelReason) pb.CancelReason {
122+ switch r {
123+ case domain.CICancelUser:
124+ return pb.CancelReason_CANCEL_REASON_USER
125+ case domain.CICancelFailFast:
126+ return pb.CancelReason_CANCEL_REASON_FAIL_FAST
127+ case domain.CICancelTimeout:
128+ return pb.CancelReason_CANCEL_REASON_TIMEOUT
129+ }
130+ return pb.CancelReason_CANCEL_REASON_UNSPECIFIED
131+}
132+
133+func runToPB(r domain.CIRun) *pb.Run {
134+ return &pb.Run{
135+ Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
136+ WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title, Event: r.Event,
137+ Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),
138+ Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),
139+ Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63
140+ }
141+}
142+
143+func stepsToPB(steps []domain.CIStep, lines int64) []*pb.Step {
144+ ranges := domain.StepRanges(steps, lines)
145+ out := make([]*pb.Step, 0, len(steps))
146+ for i, s := range steps {
147+ out = append(out, &pb.Step{
148+ Number: s.Number, Name: s.Name, State: stepStateToPB(s.State), Conclusion: conclusionToPB(s.Conclusion),
149+ StartedAt: timestamp(s.StartedAt), CompletedAt: timestamp(s.CompletedAt),
150+ LogStart: ranges[i].Start, LogEnd: ranges[i].End,
151+ })
152+ }
153+ return out
154+}
155+
156+func jobToPB(j domain.CIJob, lines int64) *pb.RunJob {
157+ return &pb.RunJob{
158+ Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, Repo: j.Repo.String(), Key: j.Key, Name: j.Name,
159+ Status: statusToPB(j.Status()), Conclusion: conclusionToPB(j.Conclusion), Error: j.Error,
160+ Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName, Isolation: isolationToPB(j.Isolation),
161+ Steps: stepsToPB(j.Steps, lines), CreatedAt: timestamp(j.CreatedAt), StartedAt: timestamp(j.StartedAt),
162+ CompletedAt: timestamp(j.CompletedAt),
163+ Version: int64(j.Version), //nolint:gosec // a store sequence, far below 2^63
164+ }
165+}
166+
167+func jobViewToPB(v ports.JobView) *pb.RunJob {
168+ out := jobToPB(v.CIJob, v.Lines)
169+ if v.RunnerGone {
170+ out.RunnerId = ""
171+ }
172+ return out
173+}
174+
175+func checkToPB(c domain.CICheck) *pb.Check {
176+ return &pb.Check{
177+ Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: statusToPB(c.Status),
178+ Conclusion: conclusionToPB(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt,
179+ WorkflowName: c.WorkflowName, StartedAt: timestamp(c.StartedAt), CompletedAt: timestamp(c.CompletedAt),
180+ }
181+}
182+
183+func runnerToPB(r ports.RunnerView) *pb.Runner {
184+ return &pb.Runner{
185+ Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: isolationToPB(r.Isolation), Version: r.Version,
186+ Arch: r.Arch, Os: r.OS, RegisteredAt: timestamp(r.RegisteredAt), LastSeen: timestamp(r.LastSeen), Busy: r.Busy,
187+ }
188+}
189+
190+func logLineToPB(l ports.JobLogLine) *pb.JobLogLine {
191+ return &pb.JobLogLine{Step: l.Step, Time: timestamp(l.Time), Text: l.Text, Continued: l.Continued}
192+}
193+
194+func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
195+ return &pb.Job{
196+ JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token,
197+ WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,
198+ Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
199+ Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref,
200+ }
201+}
202+
203+func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {
204+ out := make([]ports.StepUpdate, 0, len(steps))
205+ for _, s := range steps {
206+ concl, err := conclusionFromPB(s.GetConclusion())
207+ if err != nil {
208+ return nil, err
209+ }
210+ out = append(out, ports.StepUpdate{
211+ Number: s.GetNumber(), Name: s.GetName(), State: stepStateFromPB(s.GetState()), Conclusion: concl,
212+ StartedAt: goTime(s.GetStartedAt()), CompletedAt: goTime(s.GetCompletedAt()),
213+ })
214+ }
215+ return out, nil
216+}
217+
218+func jobEndedToPB(e *domain.JobEndedError) *pb.JobEnded {
219+ return &pb.JobEnded{Conclusion: conclusionToPB(e.Conclusion), Error: e.Message}
220+}
221+
222+func runEventToPB(ev domain.RunUpdated) *pb.Event_RunUpdated {
223+ return &pb.Event_RunUpdated{RunUpdated: &pb.RunUpdated{Run: runToPB(ev.Run)}}
224+}
225+
226+func jobEventToPB(ev domain.JobUpdated) *pb.Event_JobUpdated {
227+ return &pb.Event_JobUpdated{JobUpdated: &pb.JobUpdated{Job: jobToPB(ev.Job, ev.Lines)}}
228+}
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "fmt"
7+
8+ pb "bnema.dev/ermite-api/gen/ermite"
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports"
11+)
12+
13+func statusToPB(s domain.CIStatus) pb.CiStatus {
14+ switch s {
15+ case domain.CIQueued:
16+ return pb.CiStatus_CI_STATUS_QUEUED
17+ case domain.CIInProgress:
18+ return pb.CiStatus_CI_STATUS_IN_PROGRESS
19+ case domain.CICompleted:
20+ return pb.CiStatus_CI_STATUS_COMPLETED
21+ }
22+ return pb.CiStatus_CI_STATUS_UNSPECIFIED
23+}
24+
25+func statusFromPB(s pb.CiStatus) (domain.CIStatus, error) {
26+ switch s {
27+ case pb.CiStatus_CI_STATUS_UNSPECIFIED:
28+ return "", nil
29+ case pb.CiStatus_CI_STATUS_QUEUED:
30+ return domain.CIQueued, nil
31+ case pb.CiStatus_CI_STATUS_IN_PROGRESS:
32+ return domain.CIInProgress, nil
33+ case pb.CiStatus_CI_STATUS_COMPLETED:
34+ return domain.CICompleted, nil
35+ }
36+ return "", fmt.Errorf("%w: status %v", domain.ErrInvalid, s)
37+}
38+
39+var conclusionPairs = []struct {
40+ d domain.CIConclusion
41+ p pb.CiConclusion
42+}{
43+ {domain.CISuccess, pb.CiConclusion_CI_CONCLUSION_SUCCESS},
44+ {domain.CIFailure, pb.CiConclusion_CI_CONCLUSION_FAILURE},
45+ {domain.CICancelled, pb.CiConclusion_CI_CONCLUSION_CANCELLED},
46+ {domain.CISkipped, pb.CiConclusion_CI_CONCLUSION_SKIPPED},
47+ {domain.CITimedOut, pb.CiConclusion_CI_CONCLUSION_TIMED_OUT},
48+ {domain.CINeutral, pb.CiConclusion_CI_CONCLUSION_NEUTRAL},
49+ {domain.CIActionRequired, pb.CiConclusion_CI_CONCLUSION_ACTION_REQUIRED},
50+}
51+
52+func conclusionToPB(c domain.CIConclusion) pb.CiConclusion {
53+ for _, e := range conclusionPairs {
54+ if e.d == c {
55+ return e.p
56+ }
57+ }
58+ return pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED
59+}
60+
61+func conclusionFromPB(c pb.CiConclusion) (domain.CIConclusion, error) {
62+ if c == pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
63+ return domain.CINoConclusion, nil
64+ }
65+ for _, e := range conclusionPairs {
66+ if e.p == c {
67+ return e.d, nil
68+ }
69+ }
70+ return "", fmt.Errorf("%w: conclusion %v", domain.ErrInvalid, c)
71+}
72+
73+func isolationToPB(i domain.CIIsolation) pb.RunnerIsolation {
74+ switch i {
75+ case domain.CIIsolationVM:
76+ return pb.RunnerIsolation_RUNNER_ISOLATION_VM
77+ case domain.CIIsolationContainerSysbox:
78+ return pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX
79+ case domain.CIIsolationContainerPrivileged:
80+ return pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED
81+ }
82+ return pb.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED
83+}
84+
85+func isolationFromPB(i pb.RunnerIsolation) (domain.CIIsolation, error) {
86+ switch i {
87+ case pb.RunnerIsolation_RUNNER_ISOLATION_VM:
88+ return domain.CIIsolationVM, nil
89+ case pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX:
90+ return domain.CIIsolationContainerSysbox, nil
91+ case pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED:
92+ return domain.CIIsolationContainerPrivileged, nil
93+ }
94+ return "", fmt.Errorf("%w: isolation is required", domain.ErrInvalid)
95+}
96+
97+func stepStateToPB(s domain.CIStepState) pb.StepState {
98+ switch s {
99+ case domain.CIStepPending:
100+ return pb.StepState_STEP_STATE_PENDING
101+ case domain.CIStepInProgress:
102+ return pb.StepState_STEP_STATE_IN_PROGRESS
103+ case domain.CIStepCompleted:
104+ return pb.StepState_STEP_STATE_COMPLETED
105+ }
106+ return pb.StepState_STEP_STATE_UNSPECIFIED
107+}
108+
109+func stepStateFromPB(s pb.StepState) domain.CIStepState {
110+ switch s {
111+ case pb.StepState_STEP_STATE_PENDING:
112+ return domain.CIStepPending
113+ case pb.StepState_STEP_STATE_IN_PROGRESS:
114+ return domain.CIStepInProgress
115+ case pb.StepState_STEP_STATE_COMPLETED:
116+ return domain.CIStepCompleted
117+ }
118+ return ""
119+}
120+
121+func cancelReasonToPB(r domain.CICancelReason) pb.CancelReason {
122+ switch r {
123+ case domain.CICancelUser:
124+ return pb.CancelReason_CANCEL_REASON_USER
125+ case domain.CICancelFailFast:
126+ return pb.CancelReason_CANCEL_REASON_FAIL_FAST
127+ case domain.CICancelTimeout:
128+ return pb.CancelReason_CANCEL_REASON_TIMEOUT
129+ }
130+ return pb.CancelReason_CANCEL_REASON_UNSPECIFIED
131+}
132+
133+func runToPB(r domain.CIRun) *pb.Run {
134+ return &pb.Run{
135+ Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
136+ WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title, Event: r.Event,
137+ Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),
138+ Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),
139+ Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63
140+ }
141+}
142+
143+func stepsToPB(steps []domain.CIStep, lines int64) []*pb.Step {
144+ ranges := domain.StepRanges(steps, lines)
145+ out := make([]*pb.Step, 0, len(steps))
146+ for i, s := range steps {
147+ out = append(out, &pb.Step{
148+ Number: s.Number, Name: s.Name, State: stepStateToPB(s.State), Conclusion: conclusionToPB(s.Conclusion),
149+ StartedAt: timestamp(s.StartedAt), CompletedAt: timestamp(s.CompletedAt),
150+ LogStart: ranges[i].Start, LogEnd: ranges[i].End,
151+ })
152+ }
153+ return out
154+}
155+
156+func jobToPB(j domain.CIJob, lines int64) *pb.RunJob {
157+ return &pb.RunJob{
158+ Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, Repo: j.Repo.String(), Key: j.Key, Name: j.Name,
159+ Status: statusToPB(j.Status()), Conclusion: conclusionToPB(j.Conclusion), Error: j.Error,
160+ Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName, Isolation: isolationToPB(j.Isolation),
161+ Steps: stepsToPB(j.Steps, lines), CreatedAt: timestamp(j.CreatedAt), StartedAt: timestamp(j.StartedAt),
162+ CompletedAt: timestamp(j.CompletedAt),
163+ Version: int64(j.Version), //nolint:gosec // a store sequence, far below 2^63
164+ }
165+}
166+
167+func jobViewToPB(v ports.JobView) *pb.RunJob {
168+ out := jobToPB(v.CIJob, v.Lines)
169+ if v.RunnerGone {
170+ out.RunnerId = ""
171+ }
172+ return out
173+}
174+
175+func checkToPB(c domain.CICheck) *pb.Check {
176+ return &pb.Check{
177+ Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: statusToPB(c.Status),
178+ Conclusion: conclusionToPB(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt,
179+ WorkflowName: c.WorkflowName, StartedAt: timestamp(c.StartedAt), CompletedAt: timestamp(c.CompletedAt),
180+ }
181+}
182+
183+func runnerToPB(r ports.RunnerView) *pb.Runner {
184+ return &pb.Runner{
185+ Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: isolationToPB(r.Isolation), Version: r.Version,
186+ Arch: r.Arch, Os: r.OS, RegisteredAt: timestamp(r.RegisteredAt), LastSeen: timestamp(r.LastSeen), Busy: r.Busy,
187+ }
188+}
189+
190+func logLineToPB(l ports.JobLogLine) *pb.JobLogLine {
191+ return &pb.JobLogLine{Step: l.Step, Time: timestamp(l.Time), Text: l.Text, Continued: l.Continued}
192+}
193+
194+func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
195+ return &pb.Job{
196+ JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token,
197+ WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,
198+ Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
199+ Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref,
200+ }
201+}
202+
203+func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {
204+ out := make([]ports.StepUpdate, 0, len(steps))
205+ for _, s := range steps {
206+ concl, err := conclusionFromPB(s.GetConclusion())
207+ if err != nil {
208+ return nil, err
209+ }
210+ out = append(out, ports.StepUpdate{
211+ Number: s.GetNumber(), Name: s.GetName(), State: stepStateFromPB(s.GetState()), Conclusion: concl,
212+ StartedAt: goTime(s.GetStartedAt()), CompletedAt: goTime(s.GetCompletedAt()),
213+ })
214+ }
215+ return out, nil
216+}
217+
218+func jobEndedToPB(e *domain.JobEndedError) *pb.JobEnded {
219+ return &pb.JobEnded{Conclusion: conclusionToPB(e.Conclusion), Error: e.Message}
220+}
221+
222+func runEventToPB(ev domain.RunUpdated) *pb.Event_RunUpdated {
223+ return &pb.Event_RunUpdated{RunUpdated: &pb.RunUpdated{Run: runToPB(ev.Run)}}
224+}
225+
226+func jobEventToPB(ev domain.JobUpdated) *pb.Event_JobUpdated {
227+ return &pb.Event_JobUpdated{JobUpdated: &pb.JobUpdated{Job: jobToPB(ev.Job, ev.Lines)}}
228+}
M internal/adapters/rpc/convert.go +6 -0
@@ -391,6 +391,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
391391 case domain.CommentAdded:
392392 out.Time = timestamp(ev.At)
393393 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
394+ case domain.RunUpdated:
395+ out.Time = timestamp(ev.At)
396+ out.Kind = runEventToPB(ev)
397+ case domain.JobUpdated:
398+ out.Time = timestamp(ev.At)
399+ out.Kind = jobEventToPB(ev)
394400 default:
395401 return nil
396402 }
@@ -391,6 +391,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
391 case domain.CommentAdded:391 case domain.CommentAdded:
392 out.Time = timestamp(ev.At)392 out.Time = timestamp(ev.At)
393 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}393 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
394+ case domain.RunUpdated:
395+ out.Time = timestamp(ev.At)
396+ out.Kind = runEventToPB(ev)
397+ case domain.JobUpdated:
398+ out.Time = timestamp(ev.At)
399+ out.Kind = jobEventToPB(ev)
394 default:400 default:
395 return nil401 return nil
396 }402 }
M internal/adapters/rpc/interceptor.go +16 -0
@@ -69,6 +69,13 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
6969 if !present {
7070 return ctx, nil
7171 }
72+ if runnerSecretProcedures[procedure] {
73+ // A runner or job token: not a stored token. The use case checks it
74+ // and answers unauthenticated; runnerService.failed counts the
75+ // failures against the client address.
76+ ctx = context.WithValue(ctx, addrKey{}, addr)
77+ return context.WithValue(ctx, secretKey{}, secret), nil
78+ }
7279 p, err := i.auth.Authenticate(ctx, secret)
7380 switch {
7481 case errors.Is(err, domain.ErrUnauthenticated):
@@ -83,6 +90,15 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
8390
8491 type clientKey struct{}
8592
93+// addrKey carries the normalized client address of a call whose credential the
94+// use case checks.
95+type addrKey struct{}
96+
97+func addrFromContext(ctx context.Context) string {
98+ a, _ := ctx.Value(addrKey{}).(string)
99+ return a
100+}
101+
86102 // clientFromContext returns the normalized address of the client of a call:
87103 // its peer, empty when there is none.
88104 func clientFromContext(ctx context.Context) string {
@@ -69,6 +69,13 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
69 if !present {69 if !present {
70 return ctx, nil70 return ctx, nil
71 }71 }
72+ if runnerSecretProcedures[procedure] {
73+ // A runner or job token: not a stored token. The use case checks it
74+ // and answers unauthenticated; runnerService.failed counts the
75+ // failures against the client address.
76+ ctx = context.WithValue(ctx, addrKey{}, addr)
77+ return context.WithValue(ctx, secretKey{}, secret), nil
78+ }
72 p, err := i.auth.Authenticate(ctx, secret)79 p, err := i.auth.Authenticate(ctx, secret)
73 switch {80 switch {
74 case errors.Is(err, domain.ErrUnauthenticated):81 case errors.Is(err, domain.ErrUnauthenticated):
@@ -83,6 +90,15 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
83 90
84 type clientKey struct{}91 type clientKey struct{}
85 92
93+// addrKey carries the normalized client address of a call whose credential the
94+// use case checks.
95+type addrKey struct{}
96+
97+func addrFromContext(ctx context.Context) string {
98+ a, _ := ctx.Value(addrKey{}).(string)
99+ return a
100+}
101+
86 // clientFromContext returns the normalized address of the client of a call:102 // clientFromContext returns the normalized address of the client of a call:
87 // its peer, empty when there is none.103 // its peer, empty when there is none.
88 func clientFromContext(ctx context.Context) string {104 func clientFromContext(ctx context.Context) string {
M internal/app/api_helpers_test.go +4 -0
@@ -33,6 +33,8 @@ type clients struct {
3333 mr ermiteconnect.MergeRequestServiceClient
3434
3535 session ermiteconnect.SessionServiceClient
36+ runner ermiteconnect.RunnerServiceClient
37+ run ermiteconnect.RunServiceClient
3638 }
3739
3840 // bearerTransport adds the Authorization header of a secret, if any.
@@ -81,6 +83,8 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
8183 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
8284
8385 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
86+ runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
87+ run: ermiteconnect.NewRunServiceClient(hc, base, opts...),
8488 }
8589 }
8690
@@ -33,6 +33,8 @@ type clients struct {
33 mr ermiteconnect.MergeRequestServiceClient33 mr ermiteconnect.MergeRequestServiceClient
34 34
35 session ermiteconnect.SessionServiceClient35 session ermiteconnect.SessionServiceClient
36+ runner ermiteconnect.RunnerServiceClient
37+ run ermiteconnect.RunServiceClient
36 }38 }
37 39
38 // bearerTransport adds the Authorization header of a secret, if any.40 // bearerTransport adds the Authorization header of a secret, if any.
@@ -81,6 +83,8 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
81 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),83 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
82 84
83 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),85 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
86+ runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
87+ run: ermiteconnect.NewRunServiceClient(hc, base, opts...),
84 }88 }
85 }89 }
86 90
A internal/app/ci_e2e_test.go +443 -0
@@ -0,0 +1,443 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+ "google.golang.org/protobuf/types/known/fieldmaskpb"
17+ "google.golang.org/protobuf/types/known/timestamppb"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
23+)
24+
25+// ciHarness is a server with a repository whose pushes run workflows, and a
26+// fake runner that talks to the real Connect handlers.
27+type ciHarness struct {
28+ *harness
29+ root *clients
30+ wt string
31+ repo string
32+}
33+
34+const ciWorkflow = `name: ci
35+on:
36+ push:
37+ branches: [main, feat]
38+jobs:
39+ build:
40+ runs-on: ubuntu-latest
41+ steps:
42+ - run: echo hello
43+ test:
44+ needs: build
45+ runs-on: ubuntu-latest
46+ steps:
47+ - run: echo test
48+`
49+
50+func newCIHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {
51+ t.Helper()
52+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = tweak })
53+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
54+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
55+ require.NoError(t, err)
56+ c.wt = h.workTree("main")
57+ return c
58+}
59+
60+// push commits the files to branch and pushes it.
61+func (c *ciHarness) push(branch string, files map[string]string, msg string) string {
62+ c.t.Helper()
63+ if cur, err := c.git(c.wt, "rev-parse", "--abbrev-ref", "HEAD"); err != nil || strings.TrimSpace(cur) != branch {
64+ if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil {
65+ c.mustGit(c.wt, "checkout", "-q", "-b", branch)
66+ }
67+ }
68+ for name, content := range files {
69+ c.writeFile(c.wt, name, []byte(content))
70+ }
71+ if msg != "" {
72+ c.mustGit(c.wt, "commit", "-q", "--amend", "-m", msg)
73+ }
74+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), branch)
75+ return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
76+}
77+
78+// registerRunner registers a runner and returns a fake that polls with it.
79+func (c *ciHarness) registerRunner(name string, labels ...string) *fakeRunner {
80+ c.t.Helper()
81+ res, err := c.root.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
82+ Name: name, Labels: labels, Version: "devel", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux",
83+ }))
84+ require.NoError(c.t, err)
85+ return &fakeRunner{c: c, id: res.Msg.GetRunnerId(), api: c.api(res.Msg.GetRunnerToken())}
86+}
87+
88+// fakeRunner is the runner side of the protocol, as a test.
89+type fakeRunner struct {
90+ c *ciHarness
91+ id string
92+ api *clients
93+}
94+
95+// fetch polls once for a job.
96+func (r *fakeRunner) fetch(pollID string) (*pb.Job, error) {
97+ res, err := r.api.runner.FetchJob(r.c.ctx, connect.NewRequest(&pb.FetchJobRequest{PollId: pollID}))
98+ if err != nil {
99+ return nil, err
100+ }
101+ return res.Msg.GetJob(), nil
102+}
103+
104+// await polls until a job is given.
105+func (r *fakeRunner) await() *pb.Job {
106+ r.c.t.Helper()
107+ deadline := time.Now().Add(15 * time.Second)
108+ for time.Now().Before(deadline) {
109+ j, err := r.fetch("")
110+ require.NoError(r.c.t, err)
111+ if j != nil {
112+ return j
113+ }
114+ }
115+ r.c.t.Fatal("no job was given to the runner")
116+ return nil
117+}
118+
119+// jobAPI is the client of a job: its token is the bearer secret.
120+func (r *fakeRunner) jobAPI(j *pb.Job) *clients { return r.c.api(j.GetJobToken()) }
121+
122+func now() *timestamppb.Timestamp { return timestamppb.Now() }
123+
124+// runJob executes a job as a runner does: heartbeat, steps, log, completion.
125+func (r *fakeRunner) runJob(j *pb.Job, conclusion pb.CiConclusion, outputs map[string]string, text ...string) {
126+ r.c.t.Helper()
127+ ctx, api := r.c.ctx, r.jobAPI(j)
128+ _, err := api.runner.Heartbeat(ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j.GetJobId()}))
129+ require.NoError(r.c.t, err)
130+ start := now()
131+ _, err = api.runner.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: j.GetJobId(), Steps: []*pb.StepUpdate{
132+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: start},
133+ }}))
134+ require.NoError(r.c.t, err)
135+ var lines []*pb.LogLine
136+ for _, t := range text {
137+ lines = append(lines, &pb.LogLine{Time: now(), Text: t})
138+ }
139+ if len(lines) > 0 {
140+ _, err = api.runner.AppendLog(ctx, connect.NewRequest(&pb.AppendLogRequest{JobId: j.GetJobId(), Step: 2, Offset: 0, Lines: lines}))
141+ require.NoError(r.c.t, err)
142+ }
143+ step := pb.CiConclusion_CI_CONCLUSION_SUCCESS
144+ if conclusion != pb.CiConclusion_CI_CONCLUSION_SUCCESS {
145+ step = conclusion
146+ }
147+ res, err := api.runner.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{
148+ JobId: j.GetJobId(), Conclusion: conclusion, Outputs: outputs,
149+ Steps: []*pb.StepUpdate{
150+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
151+ {Number: 2, Name: "Run echo", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: step, StartedAt: start, CompletedAt: now()},
152+ },
153+ }))
154+ require.NoError(r.c.t, err)
155+ assert.True(r.c.t, res.Msg.GetApplied())
156+}
157+
158+// waitFor polls cond until it holds.
159+func (c *ciHarness) waitFor(what string, cond func() bool) {
160+ c.t.Helper()
161+ require.Eventually(c.t, cond, 15*time.Second, 20*time.Millisecond, what)
162+}
163+
164+func (c *ciHarness) runs() []*pb.Run {
165+ c.t.Helper()
166+ res, err := c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: c.repo}))
167+ require.NoError(c.t, err)
168+ return res.Msg.GetRuns()
169+}
170+
171+func (c *ciHarness) checks(sha string) []*pb.Check {
172+ c.t.Helper()
173+ res, err := c.root.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: c.repo, Sha: sha}))
174+ require.NoError(c.t, err)
175+ return res.Msg.GetChecks()
176+}
177+
178+func (c *ciHarness) getRun(id int64) *pb.GetRunResponse {
179+ c.t.Helper()
180+ res, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: c.repo, RunId: id}))
181+ require.NoError(c.t, err)
182+ return res.Msg
183+}
184+
185+func ciErrCode(err error) connect.Code {
186+ var ce *connect.Error
187+ if errors.As(err, &ce) {
188+ return ce.Code()
189+ }
190+ return connect.Code(0)
191+}
192+
193+// AC3, AC5: a push runs the workflow on a runner; its jobs follow their needs;
194+// the checks appear on the commit; the log is readable.
195+func TestCIPushRunsAWorkflowOnARunner(t *testing.T) {
196+ c := newCIHarness(t, nil)
197+ runner := c.registerRunner("alpha", "ubuntu-latest")
198+ sha := c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
199+
200+ c.waitFor("a run is created", func() bool { return len(c.runs()) == 1 })
201+ run := c.runs()[0]
202+ assert.Equal(t, "ci", run.GetWorkflowName())
203+ assert.Equal(t, "refs/heads/main", run.GetRef())
204+ assert.Equal(t, sha, run.GetSha())
205+ assert.Equal(t, "push", run.GetEvent())
206+ assert.EqualValues(t, 1, run.GetNumber())
207+ assert.Equal(t, "root", run.GetActor())
208+
209+ // Only the first job is ready: the second needs it.
210+ job := runner.await()
211+ assert.Equal(t, "build", job.GetJobKey())
212+ assert.Equal(t, run.GetId(), job.GetRunId())
213+ assert.Equal(t, sha, job.GetSha())
214+ assert.Equal(t, "refs/heads/main", job.GetRef())
215+ assert.Equal(t, "proj", job.GetRepo())
216+ assert.Equal(t, ciWorkflow, job.GetWorkflowYaml())
217+ assert.Equal(t, []string{"ubuntu-latest"}, job.GetLabels())
218+ assert.EqualValues(t, 360, job.GetTimeoutMinutes())
219+ assert.Equal(t, "{}", job.GetMatrix())
220+ assert.True(t, strings.HasPrefix(job.GetJobToken(), "ermitejob_"))
221+ assert.Contains(t, job.GetContexts(), `"repository":"owner/proj"`)
222+ assert.Contains(t, job.GetContexts(), `"needs":{}`)
223+
224+ // Assigned but not acknowledged: still queued for the owner.
225+ assert.Equal(t, pb.CiStatus_CI_STATUS_QUEUED, c.getRun(run.GetId()).GetJobs()[0].GetStatus())
226+
227+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"artifact": "out.bin"}, "hello", "world")
228+
229+ next := runner.await()
230+ assert.Equal(t, "test", next.GetJobKey())
231+ assert.Contains(t, next.GetContexts(), `"needs":{"build":{"result":"success","outputs":{"artifact":"out.bin"}}}`)
232+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "tested")
233+
234+ c.waitFor("the run completes", func() bool {
235+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
236+ })
237+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.runs()[0].GetConclusion())
238+
239+ checks := c.checks(sha)
240+ require.Len(t, checks, 2)
241+ assert.Equal(t, "build", checks[0].GetName())
242+ assert.Equal(t, "test", checks[1].GetName())
243+ for _, ck := range checks {
244+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, ck.GetStatus())
245+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, ck.GetConclusion())
246+ }
247+
248+ detail := c.getRun(run.GetId())
249+ require.Len(t, detail.GetJobs(), 2)
250+ steps := detail.GetJobs()[0].GetSteps()
251+ require.Len(t, steps, 2)
252+ assert.EqualValues(t, 0, steps[1].GetLogStart(), "step 2 has the lines")
253+ assert.EqualValues(t, 2, steps[1].GetLogEnd())
254+
255+ // The log is streamed back with its steps.
256+ stream, err := c.root.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: job.GetJobId()}))
257+ require.NoError(t, err)
258+ var got []string
259+ for stream.Receive() {
260+ for _, l := range stream.Msg().GetLines() {
261+ got = append(got, l.GetText())
262+ assert.EqualValues(t, 2, l.GetStep())
263+ }
264+ }
265+ require.NoError(t, stream.Err())
266+ assert.Equal(t, []string{"hello", "world"}, got)
267+}
268+
269+// AC5: merge refused while checks are pending or failing, allowed when they
270+// pass, and when failing_checks_block is off.
271+func TestCIMergeIsBlockedByChecks(t *testing.T) {
272+ c := newCIHarness(t, nil)
273+ runner := c.registerRunner("alpha", "ubuntu-latest")
274+ c.push("main", map[string]string{"README": "x\n"}, "")
275+
276+ feat := c.push("feat", map[string]string{".github/workflows/ci.yml": strings.ReplaceAll(ciWorkflow, " test:\n needs: build\n runs-on: ubuntu-latest\n steps:\n - run: echo test\n", "")}, "")
277+ mr, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
278+ require.NoError(t, err)
279+ merge := func() error {
280+ _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: mr.Msg.GetMergeRequest().GetNumber(), HeadId: feat}))
281+ return err
282+ }
283+
284+ c.waitFor("the feature run is queued", func() bool { return len(c.checks(feat)) == 1 })
285+ err = merge()
286+ require.Error(t, err)
287+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a pending check blocks the merge")
288+ assert.Contains(t, err.Error(), "have not finished")
289+
290+ job := runner.await()
291+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "boom")
292+ c.waitFor("the check fails", func() bool {
293+ cs := c.checks(feat)
294+ return len(cs) == 1 && cs[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
295+ })
296+ err = merge()
297+ require.Error(t, err)
298+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
299+ assert.Contains(t, err.Error(), "failing checks block the merge")
300+
301+ // Off: the merge goes through.
302+ _, err = c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
303+ Repo: "proj",
304+ Settings: &pb.RepoSettings{FailingChecksBlock: false},
305+ UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.failing_checks_block"}},
306+ }))
307+ require.NoError(t, err)
308+ require.NoError(t, merge())
309+}
310+
311+// AC2: a job token works for its job only, and not once the job has ended.
312+func TestCIJobTokensAreScopedToTheirJob(t *testing.T) {
313+ c := newCIHarness(t, nil)
314+ runner := c.registerRunner("alpha", "ubuntu-latest")
315+ c.push("main", map[string]string{".github/workflows/a.yml": ciWorkflow, ".github/workflows/b.yml": ciWorkflow}, "")
316+ c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
317+ j1, j2 := runner.await(), runner.await()
318+ require.NotEqual(t, j1.GetJobId(), j2.GetJobId())
319+
320+ // Another job's id with this job's token: permission_denied.
321+ _, err := runner.jobAPI(j1).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j2.GetJobId()}))
322+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
323+ // A runner token is not a job token, and a stored token is neither.
324+ _, err = runner.api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j1.GetJobId()}))
325+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
326+ _, err = c.root.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j1.GetJobId()}))
327+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
328+ // A job token does not poll.
329+ _, err = runner.jobAPI(j1).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{}))
330+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
331+
332+ runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a")
333+ // After the end: failed_precondition with the JobEnded detail; CompleteJob
334+ // still answers with the stored result.
335+ _, err = runner.jobAPI(j1).runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
336+ JobId: j1.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "late"}},
337+ }))
338+ require.Error(t, err)
339+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
340+ var ce *connect.Error
341+ require.True(t, errors.As(err, &ce))
342+ require.Len(t, ce.Details(), 1)
343+ msg, derr := ce.Details()[0].Value()
344+ require.NoError(t, derr)
345+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, msg.(*pb.JobEnded).GetConclusion())
346+
347+ res, err := runner.jobAPI(j1).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
348+ JobId: j1.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE,
349+ }))
350+ require.NoError(t, err)
351+ assert.False(t, res.Msg.GetApplied(), "a second CompleteJob changes nothing")
352+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, res.Msg.GetConclusion(), "and answers the stored conclusion")
353+}
354+
355+// AC2: a job is given to exactly one runner, however many poll at once.
356+func TestCIConcurrentFetchJobAssignsOnce(t *testing.T) {
357+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 300 * time.Millisecond })
358+ var runners []*fakeRunner
359+ for i := range 8 {
360+ runners = append(runners, c.registerRunner(fmt.Sprintf("r%d", i), "ubuntu-latest"))
361+ }
362+ var wf strings.Builder
363+ wf.WriteString("on: push\njobs:\n")
364+ for i := range 3 {
365+ fmt.Fprintf(&wf, " j%d:\n runs-on: ubuntu-latest\n steps:\n - run: echo %d\n", i, i)
366+ }
367+ c.push("main", map[string]string{".github/workflows/many.yml": wf.String()}, "")
368+ c.waitFor("the run exists", func() bool { return len(c.runs()) == 1 })
369+
370+ var mu sync.Mutex
371+ got := map[string]int{}
372+ var wg sync.WaitGroup
373+ for _, r := range runners {
374+ wg.Add(1)
375+ go func() {
376+ defer wg.Done()
377+ for range 20 {
378+ j, err := r.fetch("")
379+ if err != nil {
380+ return
381+ }
382+ if j != nil {
383+ mu.Lock()
384+ got[j.GetJobId()]++
385+ mu.Unlock()
386+ }
387+ }
388+ }()
389+ }
390+ wg.Wait()
391+ assert.Len(t, got, 3, "every job was given")
392+ for id, n := range got {
393+ assert.Equal(t, 1, n, "job %s was given %d times", id, n)
394+ }
395+}
396+
397+// AC2: the first heartbeat acknowledges; without it the assignment is
398+// cancelled and the job is queued again, three times in a row at most.
399+func TestCIUnacknowledgedAssignmentGoesBackToTheQueue(t *testing.T) {
400+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
401+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 150*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
402+ })
403+ runner := c.registerRunner("alpha", "ubuntu-latest")
404+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
405+
406+ first := runner.await()
407+ // No heartbeat: the token is revoked and the job comes back with a new one.
408+ second := runner.await()
409+ assert.Equal(t, first.GetJobId(), second.GetJobId())
410+ assert.NotEqual(t, first.GetJobToken(), second.GetJobToken())
411+ _, err := runner.jobAPI(first).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: first.GetJobId()}))
412+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err), "the replaced token is rejected")
413+
414+ // The third assignment is the last: ignoring it fails the job.
415+ runner.await()
416+ c.waitFor("the job fails", func() bool {
417+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
418+ })
419+ detail := c.getRun(c.runs()[0].GetId())
420+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, detail.GetJobs()[0].GetConclusion())
421+ assert.Contains(t, detail.GetJobs()[0].GetError(), "acknowledged")
422+}
423+
424+// A retried poll with the same poll_id returns the same assignment.
425+func TestCIRetriedPollReturnsTheSameAssignment(t *testing.T) {
426+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 200 * time.Millisecond })
427+ runner := c.registerRunner("alpha", "ubuntu-latest")
428+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
429+ c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
430+
431+ var first *pb.Job
432+ c.waitFor("a job", func() bool {
433+ j, err := runner.fetch("poll-1")
434+ require.NoError(t, err)
435+ first = j
436+ return j != nil
437+ })
438+ again, err := runner.fetch("poll-1")
439+ require.NoError(t, err)
440+ require.NotNil(t, again)
441+ assert.Equal(t, first.GetJobId(), again.GetJobId())
442+ assert.Equal(t, first.GetJobToken(), again.GetJobToken())
443+}
@@ -0,0 +1,443 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+ "google.golang.org/protobuf/types/known/fieldmaskpb"
17+ "google.golang.org/protobuf/types/known/timestamppb"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
23+)
24+
25+// ciHarness is a server with a repository whose pushes run workflows, and a
26+// fake runner that talks to the real Connect handlers.
27+type ciHarness struct {
28+ *harness
29+ root *clients
30+ wt string
31+ repo string
32+}
33+
34+const ciWorkflow = `name: ci
35+on:
36+ push:
37+ branches: [main, feat]
38+jobs:
39+ build:
40+ runs-on: ubuntu-latest
41+ steps:
42+ - run: echo hello
43+ test:
44+ needs: build
45+ runs-on: ubuntu-latest
46+ steps:
47+ - run: echo test
48+`
49+
50+func newCIHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {
51+ t.Helper()
52+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = tweak })
53+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
54+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
55+ require.NoError(t, err)
56+ c.wt = h.workTree("main")
57+ return c
58+}
59+
60+// push commits the files to branch and pushes it.
61+func (c *ciHarness) push(branch string, files map[string]string, msg string) string {
62+ c.t.Helper()
63+ if cur, err := c.git(c.wt, "rev-parse", "--abbrev-ref", "HEAD"); err != nil || strings.TrimSpace(cur) != branch {
64+ if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil {
65+ c.mustGit(c.wt, "checkout", "-q", "-b", branch)
66+ }
67+ }
68+ for name, content := range files {
69+ c.writeFile(c.wt, name, []byte(content))
70+ }
71+ if msg != "" {
72+ c.mustGit(c.wt, "commit", "-q", "--amend", "-m", msg)
73+ }
74+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), branch)
75+ return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
76+}
77+
78+// registerRunner registers a runner and returns a fake that polls with it.
79+func (c *ciHarness) registerRunner(name string, labels ...string) *fakeRunner {
80+ c.t.Helper()
81+ res, err := c.root.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
82+ Name: name, Labels: labels, Version: "devel", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux",
83+ }))
84+ require.NoError(c.t, err)
85+ return &fakeRunner{c: c, id: res.Msg.GetRunnerId(), api: c.api(res.Msg.GetRunnerToken())}
86+}
87+
88+// fakeRunner is the runner side of the protocol, as a test.
89+type fakeRunner struct {
90+ c *ciHarness
91+ id string
92+ api *clients
93+}
94+
95+// fetch polls once for a job.
96+func (r *fakeRunner) fetch(pollID string) (*pb.Job, error) {
97+ res, err := r.api.runner.FetchJob(r.c.ctx, connect.NewRequest(&pb.FetchJobRequest{PollId: pollID}))
98+ if err != nil {
99+ return nil, err
100+ }
101+ return res.Msg.GetJob(), nil
102+}
103+
104+// await polls until a job is given.
105+func (r *fakeRunner) await() *pb.Job {
106+ r.c.t.Helper()
107+ deadline := time.Now().Add(15 * time.Second)
108+ for time.Now().Before(deadline) {
109+ j, err := r.fetch("")
110+ require.NoError(r.c.t, err)
111+ if j != nil {
112+ return j
113+ }
114+ }
115+ r.c.t.Fatal("no job was given to the runner")
116+ return nil
117+}
118+
119+// jobAPI is the client of a job: its token is the bearer secret.
120+func (r *fakeRunner) jobAPI(j *pb.Job) *clients { return r.c.api(j.GetJobToken()) }
121+
122+func now() *timestamppb.Timestamp { return timestamppb.Now() }
123+
124+// runJob executes a job as a runner does: heartbeat, steps, log, completion.
125+func (r *fakeRunner) runJob(j *pb.Job, conclusion pb.CiConclusion, outputs map[string]string, text ...string) {
126+ r.c.t.Helper()
127+ ctx, api := r.c.ctx, r.jobAPI(j)
128+ _, err := api.runner.Heartbeat(ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j.GetJobId()}))
129+ require.NoError(r.c.t, err)
130+ start := now()
131+ _, err = api.runner.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: j.GetJobId(), Steps: []*pb.StepUpdate{
132+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: start},
133+ }}))
134+ require.NoError(r.c.t, err)
135+ var lines []*pb.LogLine
136+ for _, t := range text {
137+ lines = append(lines, &pb.LogLine{Time: now(), Text: t})
138+ }
139+ if len(lines) > 0 {
140+ _, err = api.runner.AppendLog(ctx, connect.NewRequest(&pb.AppendLogRequest{JobId: j.GetJobId(), Step: 2, Offset: 0, Lines: lines}))
141+ require.NoError(r.c.t, err)
142+ }
143+ step := pb.CiConclusion_CI_CONCLUSION_SUCCESS
144+ if conclusion != pb.CiConclusion_CI_CONCLUSION_SUCCESS {
145+ step = conclusion
146+ }
147+ res, err := api.runner.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{
148+ JobId: j.GetJobId(), Conclusion: conclusion, Outputs: outputs,
149+ Steps: []*pb.StepUpdate{
150+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
151+ {Number: 2, Name: "Run echo", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: step, StartedAt: start, CompletedAt: now()},
152+ },
153+ }))
154+ require.NoError(r.c.t, err)
155+ assert.True(r.c.t, res.Msg.GetApplied())
156+}
157+
158+// waitFor polls cond until it holds.
159+func (c *ciHarness) waitFor(what string, cond func() bool) {
160+ c.t.Helper()
161+ require.Eventually(c.t, cond, 15*time.Second, 20*time.Millisecond, what)
162+}
163+
164+func (c *ciHarness) runs() []*pb.Run {
165+ c.t.Helper()
166+ res, err := c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: c.repo}))
167+ require.NoError(c.t, err)
168+ return res.Msg.GetRuns()
169+}
170+
171+func (c *ciHarness) checks(sha string) []*pb.Check {
172+ c.t.Helper()
173+ res, err := c.root.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: c.repo, Sha: sha}))
174+ require.NoError(c.t, err)
175+ return res.Msg.GetChecks()
176+}
177+
178+func (c *ciHarness) getRun(id int64) *pb.GetRunResponse {
179+ c.t.Helper()
180+ res, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: c.repo, RunId: id}))
181+ require.NoError(c.t, err)
182+ return res.Msg
183+}
184+
185+func ciErrCode(err error) connect.Code {
186+ var ce *connect.Error
187+ if errors.As(err, &ce) {
188+ return ce.Code()
189+ }
190+ return connect.Code(0)
191+}
192+
193+// AC3, AC5: a push runs the workflow on a runner; its jobs follow their needs;
194+// the checks appear on the commit; the log is readable.
195+func TestCIPushRunsAWorkflowOnARunner(t *testing.T) {
196+ c := newCIHarness(t, nil)
197+ runner := c.registerRunner("alpha", "ubuntu-latest")
198+ sha := c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
199+
200+ c.waitFor("a run is created", func() bool { return len(c.runs()) == 1 })
201+ run := c.runs()[0]
202+ assert.Equal(t, "ci", run.GetWorkflowName())
203+ assert.Equal(t, "refs/heads/main", run.GetRef())
204+ assert.Equal(t, sha, run.GetSha())
205+ assert.Equal(t, "push", run.GetEvent())
206+ assert.EqualValues(t, 1, run.GetNumber())
207+ assert.Equal(t, "root", run.GetActor())
208+
209+ // Only the first job is ready: the second needs it.
210+ job := runner.await()
211+ assert.Equal(t, "build", job.GetJobKey())
212+ assert.Equal(t, run.GetId(), job.GetRunId())
213+ assert.Equal(t, sha, job.GetSha())
214+ assert.Equal(t, "refs/heads/main", job.GetRef())
215+ assert.Equal(t, "proj", job.GetRepo())
216+ assert.Equal(t, ciWorkflow, job.GetWorkflowYaml())
217+ assert.Equal(t, []string{"ubuntu-latest"}, job.GetLabels())
218+ assert.EqualValues(t, 360, job.GetTimeoutMinutes())
219+ assert.Equal(t, "{}", job.GetMatrix())
220+ assert.True(t, strings.HasPrefix(job.GetJobToken(), "ermitejob_"))
221+ assert.Contains(t, job.GetContexts(), `"repository":"owner/proj"`)
222+ assert.Contains(t, job.GetContexts(), `"needs":{}`)
223+
224+ // Assigned but not acknowledged: still queued for the owner.
225+ assert.Equal(t, pb.CiStatus_CI_STATUS_QUEUED, c.getRun(run.GetId()).GetJobs()[0].GetStatus())
226+
227+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"artifact": "out.bin"}, "hello", "world")
228+
229+ next := runner.await()
230+ assert.Equal(t, "test", next.GetJobKey())
231+ assert.Contains(t, next.GetContexts(), `"needs":{"build":{"result":"success","outputs":{"artifact":"out.bin"}}}`)
232+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "tested")
233+
234+ c.waitFor("the run completes", func() bool {
235+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
236+ })
237+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.runs()[0].GetConclusion())
238+
239+ checks := c.checks(sha)
240+ require.Len(t, checks, 2)
241+ assert.Equal(t, "build", checks[0].GetName())
242+ assert.Equal(t, "test", checks[1].GetName())
243+ for _, ck := range checks {
244+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, ck.GetStatus())
245+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, ck.GetConclusion())
246+ }
247+
248+ detail := c.getRun(run.GetId())
249+ require.Len(t, detail.GetJobs(), 2)
250+ steps := detail.GetJobs()[0].GetSteps()
251+ require.Len(t, steps, 2)
252+ assert.EqualValues(t, 0, steps[1].GetLogStart(), "step 2 has the lines")
253+ assert.EqualValues(t, 2, steps[1].GetLogEnd())
254+
255+ // The log is streamed back with its steps.
256+ stream, err := c.root.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: job.GetJobId()}))
257+ require.NoError(t, err)
258+ var got []string
259+ for stream.Receive() {
260+ for _, l := range stream.Msg().GetLines() {
261+ got = append(got, l.GetText())
262+ assert.EqualValues(t, 2, l.GetStep())
263+ }
264+ }
265+ require.NoError(t, stream.Err())
266+ assert.Equal(t, []string{"hello", "world"}, got)
267+}
268+
269+// AC5: merge refused while checks are pending or failing, allowed when they
270+// pass, and when failing_checks_block is off.
271+func TestCIMergeIsBlockedByChecks(t *testing.T) {
272+ c := newCIHarness(t, nil)
273+ runner := c.registerRunner("alpha", "ubuntu-latest")
274+ c.push("main", map[string]string{"README": "x\n"}, "")
275+
276+ feat := c.push("feat", map[string]string{".github/workflows/ci.yml": strings.ReplaceAll(ciWorkflow, " test:\n needs: build\n runs-on: ubuntu-latest\n steps:\n - run: echo test\n", "")}, "")
277+ mr, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
278+ require.NoError(t, err)
279+ merge := func() error {
280+ _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: mr.Msg.GetMergeRequest().GetNumber(), HeadId: feat}))
281+ return err
282+ }
283+
284+ c.waitFor("the feature run is queued", func() bool { return len(c.checks(feat)) == 1 })
285+ err = merge()
286+ require.Error(t, err)
287+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a pending check blocks the merge")
288+ assert.Contains(t, err.Error(), "have not finished")
289+
290+ job := runner.await()
291+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "boom")
292+ c.waitFor("the check fails", func() bool {
293+ cs := c.checks(feat)
294+ return len(cs) == 1 && cs[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
295+ })
296+ err = merge()
297+ require.Error(t, err)
298+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
299+ assert.Contains(t, err.Error(), "failing checks block the merge")
300+
301+ // Off: the merge goes through.
302+ _, err = c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
303+ Repo: "proj",
304+ Settings: &pb.RepoSettings{FailingChecksBlock: false},
305+ UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.failing_checks_block"}},
306+ }))
307+ require.NoError(t, err)
308+ require.NoError(t, merge())
309+}
310+
311+// AC2: a job token works for its job only, and not once the job has ended.
312+func TestCIJobTokensAreScopedToTheirJob(t *testing.T) {
313+ c := newCIHarness(t, nil)
314+ runner := c.registerRunner("alpha", "ubuntu-latest")
315+ c.push("main", map[string]string{".github/workflows/a.yml": ciWorkflow, ".github/workflows/b.yml": ciWorkflow}, "")
316+ c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
317+ j1, j2 := runner.await(), runner.await()
318+ require.NotEqual(t, j1.GetJobId(), j2.GetJobId())
319+
320+ // Another job's id with this job's token: permission_denied.
321+ _, err := runner.jobAPI(j1).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j2.GetJobId()}))
322+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
323+ // A runner token is not a job token, and a stored token is neither.
324+ _, err = runner.api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j1.GetJobId()}))
325+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
326+ _, err = c.root.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j1.GetJobId()}))
327+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
328+ // A job token does not poll.
329+ _, err = runner.jobAPI(j1).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{}))
330+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
331+
332+ runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a")
333+ // After the end: failed_precondition with the JobEnded detail; CompleteJob
334+ // still answers with the stored result.
335+ _, err = runner.jobAPI(j1).runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
336+ JobId: j1.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "late"}},
337+ }))
338+ require.Error(t, err)
339+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
340+ var ce *connect.Error
341+ require.True(t, errors.As(err, &ce))
342+ require.Len(t, ce.Details(), 1)
343+ msg, derr := ce.Details()[0].Value()
344+ require.NoError(t, derr)
345+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, msg.(*pb.JobEnded).GetConclusion())
346+
347+ res, err := runner.jobAPI(j1).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
348+ JobId: j1.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE,
349+ }))
350+ require.NoError(t, err)
351+ assert.False(t, res.Msg.GetApplied(), "a second CompleteJob changes nothing")
352+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, res.Msg.GetConclusion(), "and answers the stored conclusion")
353+}
354+
355+// AC2: a job is given to exactly one runner, however many poll at once.
356+func TestCIConcurrentFetchJobAssignsOnce(t *testing.T) {
357+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 300 * time.Millisecond })
358+ var runners []*fakeRunner
359+ for i := range 8 {
360+ runners = append(runners, c.registerRunner(fmt.Sprintf("r%d", i), "ubuntu-latest"))
361+ }
362+ var wf strings.Builder
363+ wf.WriteString("on: push\njobs:\n")
364+ for i := range 3 {
365+ fmt.Fprintf(&wf, " j%d:\n runs-on: ubuntu-latest\n steps:\n - run: echo %d\n", i, i)
366+ }
367+ c.push("main", map[string]string{".github/workflows/many.yml": wf.String()}, "")
368+ c.waitFor("the run exists", func() bool { return len(c.runs()) == 1 })
369+
370+ var mu sync.Mutex
371+ got := map[string]int{}
372+ var wg sync.WaitGroup
373+ for _, r := range runners {
374+ wg.Add(1)
375+ go func() {
376+ defer wg.Done()
377+ for range 20 {
378+ j, err := r.fetch("")
379+ if err != nil {
380+ return
381+ }
382+ if j != nil {
383+ mu.Lock()
384+ got[j.GetJobId()]++
385+ mu.Unlock()
386+ }
387+ }
388+ }()
389+ }
390+ wg.Wait()
391+ assert.Len(t, got, 3, "every job was given")
392+ for id, n := range got {
393+ assert.Equal(t, 1, n, "job %s was given %d times", id, n)
394+ }
395+}
396+
397+// AC2: the first heartbeat acknowledges; without it the assignment is
398+// cancelled and the job is queued again, three times in a row at most.
399+func TestCIUnacknowledgedAssignmentGoesBackToTheQueue(t *testing.T) {
400+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
401+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 150*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
402+ })
403+ runner := c.registerRunner("alpha", "ubuntu-latest")
404+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
405+
406+ first := runner.await()
407+ // No heartbeat: the token is revoked and the job comes back with a new one.
408+ second := runner.await()
409+ assert.Equal(t, first.GetJobId(), second.GetJobId())
410+ assert.NotEqual(t, first.GetJobToken(), second.GetJobToken())
411+ _, err := runner.jobAPI(first).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: first.GetJobId()}))
412+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err), "the replaced token is rejected")
413+
414+ // The third assignment is the last: ignoring it fails the job.
415+ runner.await()
416+ c.waitFor("the job fails", func() bool {
417+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
418+ })
419+ detail := c.getRun(c.runs()[0].GetId())
420+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, detail.GetJobs()[0].GetConclusion())
421+ assert.Contains(t, detail.GetJobs()[0].GetError(), "acknowledged")
422+}
423+
424+// A retried poll with the same poll_id returns the same assignment.
425+func TestCIRetriedPollReturnsTheSameAssignment(t *testing.T) {
426+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 200 * time.Millisecond })
427+ runner := c.registerRunner("alpha", "ubuntu-latest")
428+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
429+ c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
430+
431+ var first *pb.Job
432+ c.waitFor("a job", func() bool {
433+ j, err := runner.fetch("poll-1")
434+ require.NoError(t, err)
435+ first = j
436+ return j != nil
437+ })
438+ again, err := runner.fetch("poll-1")
439+ require.NoError(t, err)
440+ require.NotNil(t, again)
441+ assert.Equal(t, first.GetJobId(), again.GetJobId())
442+ assert.Equal(t, first.GetJobToken(), again.GetJobToken())
443+}
M internal/app/e2e_helpers_test.go +11 -1
@@ -53,6 +53,12 @@ type harnessOpts struct {
5353 // newHarness starts a forge. The data directory is short: the hook socket
5454 // lives in it and Unix socket paths are limited to about 100 bytes.
5555 func newHarness(t *testing.T, o harnessOpts) *harness {
56+ t.Helper()
57+ return newHarnessWith(t, o, nil)
58+}
59+
60+// newHarnessWith is newHarness with a change of the wiring options.
61+func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness {
5662 t.Helper()
5763 gitBin := requireGit(t)
5864 data, err := os.MkdirTemp("", "ermite")
@@ -85,7 +91,11 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
8591 if hookExe == "" {
8692 hookExe = binary(t)
8793 }
88- f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery})
94+ opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}
95+ if tweak != nil {
96+ tweak(&opts)
97+ }
98+ f, err := newForge(ctx, cfg, opts)
8999 require.NoError(t, err)
90100
91101 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
@@ -53,6 +53,12 @@ type harnessOpts struct {
53 // newHarness starts a forge. The data directory is short: the hook socket53 // newHarness starts a forge. The data directory is short: the hook socket
54 // lives in it and Unix socket paths are limited to about 100 bytes.54 // lives in it and Unix socket paths are limited to about 100 bytes.
55 func newHarness(t *testing.T, o harnessOpts) *harness {55 func newHarness(t *testing.T, o harnessOpts) *harness {
56+ t.Helper()
57+ return newHarnessWith(t, o, nil)
58+}
59+
60+// newHarnessWith is newHarness with a change of the wiring options.
61+func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness {
56 t.Helper()62 t.Helper()
57 gitBin := requireGit(t)63 gitBin := requireGit(t)
58 data, err := os.MkdirTemp("", "ermite")64 data, err := os.MkdirTemp("", "ermite")
@@ -85,7 +91,11 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
85 if hookExe == "" {91 if hookExe == "" {
86 hookExe = binary(t)92 hookExe = binary(t)
87 }93 }
88- f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery})94+ opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}
95+ if tweak != nil {
96+ tweak(&opts)
97+ }
98+ f, err := newForge(ctx, cfg, opts)
89 require.NoError(t, err)99 require.NoError(t, err)
90 100
91 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}101 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
M internal/app/forge.go +30 -2
@@ -15,6 +15,7 @@ import (
1515
1616 "github.com/bnema/zerowrap"
1717
18+ "bnema.dev/ermite/internal/adapters/cilogs"
1819 "bnema.dev/ermite/internal/adapters/ermitegit"
1920 "bnema.dev/ermite/internal/adapters/eventbus"
2021 "bnema.dev/ermite/internal/adapters/gitcli"
@@ -48,6 +49,9 @@ type options struct {
4849 eventBuffer int
4950 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
5051 revalidateEvery time.Duration
52+ // ci overrides the settings of the CI engine (tests shorten its
53+ // durations).
54+ ci func(*usecase.CIConfig)
5155 }
5256
5357 // forge is the wired server: every adapter and use case, ready to serve.
@@ -74,8 +78,11 @@ type forge struct {
7478 SigningKey *usecase.SigningKey
7579 Sessions *usecase.Sessions
7680 EventLog *usecase.Events
77- API *rpc.API
78- Bus ports.EventBus
81+ // CI is the CI engine: workflow trigger, planner, runner protocol and the
82+ // owner side. It starts with the server and stops with it.
83+ CI *usecase.CI
84+ API *rpc.API
85+ Bus ports.EventBus
7986 }
8087
8188 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -182,6 +189,21 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
182189 }
183190 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
184191 f.Browse = usecase.NewBrowse(store, reader)
192+
193+ // CI (ADR 0025): the engine reads workflows through the reader, and plans
194+ // them in the background; a push only queues its refs.
195+ ciCfg := usecase.CIConfig{
196+ Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
197+ Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
198+ Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
199+ }
200+ if opts.ci != nil {
201+ opts.ci(&ciCfg)
202+ }
203+ f.CI = usecase.NewCI(ctx, ciCfg)
204+ f.Pushes.WithWorkflowTrigger(f.CI)
205+ f.Repos.WithCIRunCleaner(f.CI)
206+ f.CI.Start()
185207 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
186208 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
187209 })
@@ -200,6 +222,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
200222 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
201223 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
202224 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
225+ CI: f.CI, Checks: f.CI,
203226 })
204227 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
205228
@@ -220,6 +243,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
220243 MergeRequests: f.MergeRequests,
221244 SigningKey: f.SigningKey,
222245 Sessions: f.Sessions,
246+ Runner: f.CI,
247+ Runs: f.CI,
223248 Version: serverVersion(),
224249 TrustedProxies: cfg.TrustedProxies,
225250 Limiter: limiter,
@@ -297,6 +322,9 @@ func (f *forge) Close(ctx context.Context) {
297322 if f.hooks != nil {
298323 f.hooks.Close(ctx)
299324 }
325+ if f.CI != nil {
326+ f.CI.Close(ctx)
327+ }
300328 if f.EventLog != nil {
301329 f.EventLog.Close(ctx)
302330 }
@@ -15,6 +15,7 @@ import (
15 15
16 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
17 17
18+ "bnema.dev/ermite/internal/adapters/cilogs"
18 "bnema.dev/ermite/internal/adapters/ermitegit"19 "bnema.dev/ermite/internal/adapters/ermitegit"
19 "bnema.dev/ermite/internal/adapters/eventbus"20 "bnema.dev/ermite/internal/adapters/eventbus"
20 "bnema.dev/ermite/internal/adapters/gitcli"21 "bnema.dev/ermite/internal/adapters/gitcli"
@@ -48,6 +49,9 @@ type options struct {
48 eventBuffer int49 eventBuffer int
49 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.50 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
50 revalidateEvery time.Duration51 revalidateEvery time.Duration
52+ // ci overrides the settings of the CI engine (tests shorten its
53+ // durations).
54+ ci func(*usecase.CIConfig)
51 }55 }
52 56
53 // forge is the wired server: every adapter and use case, ready to serve.57 // forge is the wired server: every adapter and use case, ready to serve.
@@ -74,8 +78,11 @@ type forge struct {
74 SigningKey *usecase.SigningKey78 SigningKey *usecase.SigningKey
75 Sessions *usecase.Sessions79 Sessions *usecase.Sessions
76 EventLog *usecase.Events80 EventLog *usecase.Events
77- API *rpc.API81+ // CI is the CI engine: workflow trigger, planner, runner protocol and the
78- Bus ports.EventBus82+ // owner side. It starts with the server and stops with it.
83+ CI *usecase.CI
84+ API *rpc.API
85+ Bus ports.EventBus
79 }86 }
80 87
81 // newSigningSeed makes the seed of a new ed25519 signing key.88 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -182,6 +189,21 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
182 }189 }
183 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")190 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
184 f.Browse = usecase.NewBrowse(store, reader)191 f.Browse = usecase.NewBrowse(store, reader)
192+
193+ // CI (ADR 0025): the engine reads workflows through the reader, and plans
194+ // them in the background; a push only queues its refs.
195+ ciCfg := usecase.CIConfig{
196+ Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
197+ Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
198+ Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
199+ }
200+ if opts.ci != nil {
201+ opts.ci(&ciCfg)
202+ }
203+ f.CI = usecase.NewCI(ctx, ciCfg)
204+ f.Pushes.WithWorkflowTrigger(f.CI)
205+ f.Repos.WithCIRunCleaner(f.CI)
206+ f.CI.Start()
185 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{207 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
186 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,208 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
187 })209 })
@@ -200,6 +222,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
200 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{222 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
201 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,223 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
202 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,224 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
225+ CI: f.CI, Checks: f.CI,
203 })226 })
204 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")227 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
205 228
@@ -220,6 +243,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
220 MergeRequests: f.MergeRequests,243 MergeRequests: f.MergeRequests,
221 SigningKey: f.SigningKey,244 SigningKey: f.SigningKey,
222 Sessions: f.Sessions,245 Sessions: f.Sessions,
246+ Runner: f.CI,
247+ Runs: f.CI,
223 Version: serverVersion(),248 Version: serverVersion(),
224 TrustedProxies: cfg.TrustedProxies,249 TrustedProxies: cfg.TrustedProxies,
225 Limiter: limiter,250 Limiter: limiter,
@@ -297,6 +322,9 @@ func (f *forge) Close(ctx context.Context) {
297 if f.hooks != nil {322 if f.hooks != nil {
298 f.hooks.Close(ctx)323 f.hooks.Close(ctx)
299 }324 }
325+ if f.CI != nil {
326+ f.CI.Close(ctx)
327+ }
300 if f.EventLog != nil {328 if f.EventLog != nil {
301 f.EventLog.Close(ctx)329 f.EventLog.Close(ctx)
302 }330 }
M internal/ports/ci.go +6 -0
@@ -300,6 +300,12 @@ func (v JobView) StepRanges() []domain.StepRange { return domain.StepRanges(v.St
300300 type ListRunsParams struct {
301301 PageSize int
302302 PageToken string
303+ // Workflow restricts the result to a workflow file: its path or its file
304+ // name. Branch restricts it to the runs of a branch, by short name. Status
305+ // restricts it to the runs of that status. Empty means no restriction.
306+ Workflow string
307+ Branch string
308+ Status domain.CIStatus
303309 }
304310
305311 // RunPage is a page of runs, newest first.
@@ -300,6 +300,12 @@ func (v JobView) StepRanges() []domain.StepRange { return domain.StepRanges(v.St
300 type ListRunsParams struct {300 type ListRunsParams struct {
301 PageSize int301 PageSize int
302 PageToken string302 PageToken string
303+ // Workflow restricts the result to a workflow file: its path or its file
304+ // name. Branch restricts it to the runs of a branch, by short name. Status
305+ // restricts it to the runs of that status. Empty means no restriction.
306+ Workflow string
307+ Branch string
308+ Status domain.CIStatus
303 }309 }
304 310
305 // RunPage is a page of runs, newest first.311 // RunPage is a page of runs, newest first.
M internal/ports/dto.go +2 -2
@@ -137,8 +137,8 @@ type WatchEvent struct {
137137 // Event is nil then.
138138 Gap bool
139139 // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,
140- // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or
141- // CommentAdded.
140+ // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed,
141+ // CommentAdded, RunUpdated or JobUpdated.
142142 Event domain.Event
143143 // Repo is the state of the repository at delivery, for RepoCreated and
144144 // RepoUpdated.
@@ -137,8 +137,8 @@ type WatchEvent struct {
137 // Event is nil then.137 // Event is nil then.
138 Gap bool138 Gap bool
139 // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,139 // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,
140- // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or140+ // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed,
141- // CommentAdded.141+ // CommentAdded, RunUpdated or JobUpdated.
142 Event domain.Event142 Event domain.Event
143 // Repo is the state of the repository at delivery, for RepoCreated and143 // Repo is the state of the repository at delivery, for RepoCreated and
144 // RepoUpdated.144 // RepoUpdated.
M internal/usecase/ci_runs.go +39 -7
@@ -7,6 +7,7 @@ import (
77 "encoding/base64"
88 "errors"
99 "fmt"
10+ "path"
1011 "slices"
1112 "strconv"
1213 "strings"
@@ -39,18 +40,49 @@ func (c *CI) ListRuns(ctx context.Context, p domain.Principal, name domain.RepoN
3940 }
4041 before = id
4142 }
42- runs, err := c.cfg.Store.ListRuns(ctx, name, before, n+1)
43- if err != nil {
44- return ports.RunPage{}, err
43+ match := runFilter(params)
44+ // The store returns runs by id; the filters are applied while paging, so a
45+ // page of a filtered listing is full unless the runs ran out.
46+ var kept []domain.CIRun
47+ cursor := before
48+ for len(kept) <= n {
49+ batch, err := c.cfg.Store.ListRuns(ctx, name, cursor, n+1)
50+ if err != nil {
51+ return ports.RunPage{}, err
52+ }
53+ for _, r := range batch {
54+ if match(r) {
55+ kept = append(kept, r)
56+ }
57+ }
58+ if len(batch) < n+1 {
59+ break
60+ }
61+ cursor = batch[len(batch)-1].ID
4562 }
46- page := ports.RunPage{Runs: runs}
47- if len(runs) > n {
48- page.Runs = runs[:n]
49- page.NextPageToken = base64.RawURLEncoding.EncodeToString([]byte(runTokenPrefix + strconv.FormatInt(runs[n-1].ID, 10)))
63+ page := ports.RunPage{Runs: kept}
64+ if len(kept) > n {
65+ page.Runs = kept[:n]
66+ page.NextPageToken = base64.RawURLEncoding.EncodeToString([]byte(runTokenPrefix + strconv.FormatInt(kept[n-1].ID, 10)))
5067 }
5168 return page, nil
5269 }
5370
71+// runFilter returns the test of the filters of a listing.
72+func runFilter(p ports.ListRunsParams) func(domain.CIRun) bool {
73+ return func(r domain.CIRun) bool {
74+ switch {
75+ case p.Status != "" && r.Status != p.Status:
76+ return false
77+ case p.Branch != "" && r.Ref != "refs/heads/"+p.Branch:
78+ return false
79+ case p.Workflow != "" && r.WorkflowPath != p.Workflow && path.Base(r.WorkflowPath) != p.Workflow:
80+ return false
81+ }
82+ return true
83+ }
84+}
85+
5486 // runOf finds a run of the repository; a run of another repository is not
5587 // found.
5688 func (c *CI) runOf(ctx context.Context, name domain.RepoName, runID int64) (domain.CIRun, error) {
@@ -7,6 +7,7 @@ import (
7 "encoding/base64"7 "encoding/base64"
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10+ "path"
10 "slices"11 "slices"
11 "strconv"12 "strconv"
12 "strings"13 "strings"
@@ -39,18 +40,49 @@ func (c *CI) ListRuns(ctx context.Context, p domain.Principal, name domain.RepoN
39 }40 }
40 before = id41 before = id
41 }42 }
42- runs, err := c.cfg.Store.ListRuns(ctx, name, before, n+1)43+ match := runFilter(params)
43- if err != nil {44+ // The store returns runs by id; the filters are applied while paging, so a
44- return ports.RunPage{}, err45+ // page of a filtered listing is full unless the runs ran out.
46+ var kept []domain.CIRun
47+ cursor := before
48+ for len(kept) <= n {
49+ batch, err := c.cfg.Store.ListRuns(ctx, name, cursor, n+1)
50+ if err != nil {
51+ return ports.RunPage{}, err
52+ }
53+ for _, r := range batch {
54+ if match(r) {
55+ kept = append(kept, r)
56+ }
57+ }
58+ if len(batch) < n+1 {
59+ break
60+ }
61+ cursor = batch[len(batch)-1].ID
45 }62 }
46- page := ports.RunPage{Runs: runs}63+ page := ports.RunPage{Runs: kept}
47- if len(runs) > n {64+ if len(kept) > n {
48- page.Runs = runs[:n]65+ page.Runs = kept[:n]
49- page.NextPageToken = base64.RawURLEncoding.EncodeToString([]byte(runTokenPrefix + strconv.FormatInt(runs[n-1].ID, 10)))66+ page.NextPageToken = base64.RawURLEncoding.EncodeToString([]byte(runTokenPrefix + strconv.FormatInt(kept[n-1].ID, 10)))
50 }67 }
51 return page, nil68 return page, nil
52 }69 }
53 70
71+// runFilter returns the test of the filters of a listing.
72+func runFilter(p ports.ListRunsParams) func(domain.CIRun) bool {
73+ return func(r domain.CIRun) bool {
74+ switch {
75+ case p.Status != "" && r.Status != p.Status:
76+ return false
77+ case p.Branch != "" && r.Ref != "refs/heads/"+p.Branch:
78+ return false
79+ case p.Workflow != "" && r.WorkflowPath != p.Workflow && path.Base(r.WorkflowPath) != p.Workflow:
80+ return false
81+ }
82+ return true
83+ }
84+}
85+
54 // runOf finds a run of the repository; a run of another repository is not86 // runOf finds a run of the repository; a run of another repository is not
55 // found.87 // found.
56 func (c *CI) runOf(ctx context.Context, name domain.RepoName, runID int64) (domain.CIRun, error) {88 func (c *CI) runOf(ctx context.Context, name domain.RepoName, runID int64) (domain.CIRun, error) {
M internal/usecase/events.go +15 -0
@@ -176,6 +176,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
176176 return repoSnapshot(ev.Visibility, ev.CreatedAt)
177177 case domain.CommentAdded:
178178 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179+ case domain.RunUpdated:
180+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
181+ case domain.JobUpdated:
182+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
179183 }
180184 return bufferedEvent{known: true}
181185 }
@@ -479,6 +483,17 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
479483 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
480484 case domain.CommentAdded:
481485 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
486+ case domain.RunUpdated:
487+ // CI events go on the topic of their repository only, never on home.
488+ if !w.repos[ev.Run.Repo] {
489+ return nil
490+ }
491+ return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
492+ case domain.JobUpdated:
493+ if !w.repos[ev.Job.Repo] {
494+ return nil
495+ }
496+ return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
482497 }
483498 return nil // an event this version does not stream
484499 }
@@ -176,6 +176,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
176 return repoSnapshot(ev.Visibility, ev.CreatedAt)176 return repoSnapshot(ev.Visibility, ev.CreatedAt)
177 case domain.CommentAdded:177 case domain.CommentAdded:
178 return repoSnapshot(ev.Visibility, ev.CreatedAt)178 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179+ case domain.RunUpdated:
180+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
181+ case domain.JobUpdated:
182+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
179 }183 }
180 return bufferedEvent{known: true}184 return bufferedEvent{known: true}
181 }185 }
@@ -479,6 +483,17 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
479 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)483 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
480 case domain.CommentAdded:484 case domain.CommentAdded:
481 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)485 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
486+ case domain.RunUpdated:
487+ // CI events go on the topic of their repository only, never on home.
488+ if !w.repos[ev.Run.Repo] {
489+ return nil
490+ }
491+ return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
492+ case domain.JobUpdated:
493+ if !w.repos[ev.Job.Repo] {
494+ return nil
495+ }
496+ return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
482 }497 }
483 return nil // an event this version does not stream498 return nil // an event this version does not stream
484 }499 }
M internal/usecase/repos.go +8 -0
@@ -21,6 +21,7 @@ type Repos struct {
2121 git ports.GitRepos
2222 bus ports.EventBus
2323 clock ports.Clock
24+ ci ports.CIRunCleaner // nil: no CI logs to remove
2425 }
2526
2627 var _ ports.RepoService = (*Repos)(nil)
@@ -31,6 +32,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus,
3132 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
3233 }
3334
35+// WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
36+// before the first request; it returns r.
37+func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
38+ r.ci = c
39+ return r
40+}
41+
3442 // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
3543 // metadata (which fails with domain.ErrAlreadyExists if the name is taken,
3644 // also when a concurrent Create wins) and publishes the directory. A failure
@@ -21,6 +21,7 @@ type Repos struct {
21 git ports.GitRepos21 git ports.GitRepos
22 bus ports.EventBus22 bus ports.EventBus
23 clock ports.Clock23 clock ports.Clock
24+ ci ports.CIRunCleaner // nil: no CI logs to remove
24 }25 }
25 26
26 var _ ports.RepoService = (*Repos)(nil)27 var _ ports.RepoService = (*Repos)(nil)
@@ -31,6 +32,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus,
31 return &Repos{meta: meta, git: git, bus: bus, clock: clock}32 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
32 }33 }
33 34
35+// WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
36+// before the first request; it returns r.
37+func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
38+ r.ci = c
39+ return r
40+}
41+
34 // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the42 // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
35 // metadata (which fails with domain.ErrAlreadyExists if the name is taken,43 // metadata (which fails with domain.ErrAlreadyExists if the name is taken,
36 // also when a concurrent Create wins) and publishes the directory. A failure44 // also when a concurrent Create wins) and publishes the directory. A failure
M internal/usecase/repos_write.go +14 -0
@@ -191,9 +191,23 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
191191 case err != nil:
192192 return fmt.Errorf("trash repository %q: %w", name, err)
193193 }
194+ var runIDs []int64
195+ if r.ci != nil {
196+ // Listed before the records go: the logs are found by run id.
197+ if ids, err := r.ci.RunIDs(ctx, name); err == nil {
198+ runIDs = ids
199+ } else {
200+ log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")
201+ }
202+ }
194203 if err := r.meta.Delete(ctx, name); err != nil {
195204 return fmt.Errorf("delete metadata of %q: %w", name, err)
196205 }
206+ if len(runIDs) > 0 {
207+ if err := r.ci.RemoveLogs(context.WithoutCancel(ctx), runIDs); err != nil {
208+ log.Warn().Err(err).Str("repo", name.String()).Msg("remove the CI logs of the repository; the sweep will")
209+ }
210+ }
197211 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
198212 r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
199213 return nil
@@ -191,9 +191,23 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
191 case err != nil:191 case err != nil:
192 return fmt.Errorf("trash repository %q: %w", name, err)192 return fmt.Errorf("trash repository %q: %w", name, err)
193 }193 }
194+ var runIDs []int64
195+ if r.ci != nil {
196+ // Listed before the records go: the logs are found by run id.
197+ if ids, err := r.ci.RunIDs(ctx, name); err == nil {
198+ runIDs = ids
199+ } else {
200+ log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")
201+ }
202+ }
194 if err := r.meta.Delete(ctx, name); err != nil {203 if err := r.meta.Delete(ctx, name); err != nil {
195 return fmt.Errorf("delete metadata of %q: %w", name, err)204 return fmt.Errorf("delete metadata of %q: %w", name, err)
196 }205 }
206+ if len(runIDs) > 0 {
207+ if err := r.ci.RemoveLogs(context.WithoutCancel(ctx), runIDs); err != nil {
208+ log.Warn().Err(err).Str("repo", name.String()).Msg("remove the CI logs of the repository; the sweep will")
209+ }
210+ }
197 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")211 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
198 r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})212 r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
199 return nil213 return nil

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close