v0.32.0 53e25a3

ci_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"context"
7	"testing"
8	"time"
9
10	"connectrpc.com/connect"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	pb "bnema.dev/ermite-api/gen/ermite"
16	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"bnema.dev/ermite/internal/ports/mocks"
20)
21
22// RunService.GetJob: the job with its steps and the range of their lines, the
23// states and conclusions of the contract, and the codes of the refusals.
24func TestRunServiceGetJob(t *testing.T) {
25	t.Parallel()
26	at := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC)
27	job := ports.JobView{CIJob: domain.CIJob{
28		ID: "7-0-0", RunID: 7, Attempt: 2, RunAttempt: 2, Repo: mustRepoName(t, "demo"), Key: "build", Name: "build (linux)",
29		Phase: domain.CIPhaseEnded, Conclusion: domain.CIFailure, Error: "boom", Labels: []string{"self-hosted"}, Needs: []string{"lint"},
30		RunnerName: "alpha", Isolation: domain.CIIsolationVM, CreatedAt: at, StartedAt: at, CompletedAt: at.Add(time.Minute), Version: 9,
31		Steps: []domain.CIStep{
32			{Number: 1, Name: "Set up job", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 0},
33			{Number: 2, Name: "Run make", State: domain.CIStepCompleted, Conclusion: domain.CIFailure, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 3},
34		},
35	}, Lines: 8}
36
37	runs := mocks.NewRunService(t)
38	runs.EXPECT().GetJob(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), "7-0-0", int32(2)).Return(job, nil).Once()
39	resp, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: "demo", JobId: "7-0-0", Attempt: 2}))
40	require.NoError(t, err)
41	got := resp.Msg.GetJob()
42	assert.Equal(t, "7-0-0", got.GetId())
43	assert.EqualValues(t, 2, got.GetAttempt())
44	assert.Equal(t, "build (linux)", got.GetName())
45	assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, got.GetStatus())
46	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, got.GetConclusion())
47	assert.Equal(t, "boom", got.GetError())
48	assert.Equal(t, pb.RunnerIsolation_RUNNER_ISOLATION_VM, got.GetIsolation())
49	assert.Equal(t, []string{"lint"}, got.GetNeeds())
50	assert.EqualValues(t, 9, got.GetVersion())
51	require.Len(t, got.GetSteps(), 2)
52	assert.Equal(t, []int64{0, 3}, []int64{got.GetSteps()[0].GetLogStart(), got.GetSteps()[0].GetLogEnd()}, "a step ends where the next begins")
53	assert.Equal(t, []int64{3, 8}, []int64{got.GetSteps()[1].GetLogStart(), got.GetSteps()[1].GetLogEnd()}, "the last ends at the end of the log")
54	assert.Equal(t, pb.StepState_STEP_STATE_COMPLETED, got.GetSteps()[1].GetState())
55
56	for name, tc := range map[string]struct {
57		repo string
58		err  error
59		code connect.Code
60	}{
61		"invalid repo":  {"../x", nil, connect.CodeInvalidArgument},
62		"unknown job":   {"demo", domain.ErrNotFound, connect.CodeNotFound},
63		"not readable":  {"demo", domain.ErrNotFound, connect.CodeNotFound},
64		"store failure": {"demo", assert.AnError, connect.CodeInternal},
65	} {
66		t.Run(name, func(t *testing.T) {
67			t.Parallel()
68			runs := mocks.NewRunService(t)
69			if tc.err != nil {
70				runs.EXPECT().GetJob(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.JobView{}, tc.err).Once()
71			}
72			_, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: tc.repo, JobId: "7-0-0"}))
73			requireCode(t, tc.code, err)
74		})
75	}
76}
77
78// The end of a job reaches the runner as failed_precondition with the JobEnded
79// detail; a bad credential counts against the client address.
80func TestRunnerServiceErrors(t *testing.T) {
81	t.Parallel()
82	ended := &domain.JobEndedError{Conclusion: domain.CICancelled, Message: "stopped"}
83	err := ciError(ownerCtx(), ended)
84	requireCode(t, connect.CodeFailedPrecondition, err)
85	var ce *connect.Error
86	require.ErrorAs(t, err, &ce)
87	require.Len(t, ce.Details(), 1)
88	msg, derr := ce.Details()[0].Value()
89	require.NoError(t, derr)
90	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, msg.(*pb.JobEnded).GetConclusion())
91	assert.Equal(t, "stopped", msg.(*pb.JobEnded).GetError())
92
93	requireCode(t, connect.CodeOutOfRange, ciError(ownerCtx(), &ports.ErrCILogGap{Count: 4}))
94
95	limiter := mocks.NewFailureLimiter(t)
96	limiter.EXPECT().Fail(mock.Anything, "client-key").Return().Once()
97	s := &runnerService{runners: mocks.NewRunnerService(t), limiter: limiter}
98	ctx := t.Context()
99	ctx = context.WithValue(ctx, addrKey{}, "client-key")
100	requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated))
101	requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail
102}
103
104// The annotations of a request are mapped to the domain; a level that is not
105// one of the three is invalid_argument before the use case sees the call.
106func TestRunnerServiceAnnotationsAndSummary(t *testing.T) {
107	t.Parallel()
108	anns := []*pb.Annotation{
109		{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "t", Message: "m", File: "f.go", StartLine: 1, EndLine: 2, StartColumn: 3, EndColumn: 4, StepNumber: 5},
110		{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "n", StepNumber: 1},
111	}
112	want := []domain.CIAnnotation{
113		{Level: domain.AnnotationFailure, Title: "t", Message: "m", File: "f.go", StartLine: 1, EndLine: 2, StartColumn: 3, EndColumn: 4, Step: 5},
114		{Level: domain.AnnotationNotice, Message: "n", Step: 1},
115	}
116	ctx := context.WithValue(ownerCtx(), secretKey{}, "ermitejob_x")
117
118	runners := mocks.NewRunnerService(t)
119	runners.EXPECT().UpdateJob(mock.Anything, "ermitejob_x", "7-0-0", mock.Anything, want).Return(nil).Once()
120	runners.EXPECT().CompleteJob(mock.Anything, "ermitejob_x", mock.MatchedBy(func(p ports.CompleteJobParams) bool {
121		return p.JobID == "7-0-0" && len(p.Annotations) == 2 && p.Annotations[0] == want[0]
122	})).Return(ports.CompleteJobResult{Conclusion: domain.CISuccess, Applied: true}, nil).Once()
123	runners.EXPECT().UploadSummary(mock.Anything, "ermitejob_x", "7-0-0", int32(3), "# md").Return(nil).Once()
124	runners.EXPECT().UploadSummary(mock.Anything, "ermitejob_x", "7-0-0", int32(3), "").Return(&domain.JobEndedError{Conclusion: domain.CIFailure}).Once()
125	s := &runnerService{runners: runners}
126
127	_, err := s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: anns}))
128	require.NoError(t, err)
129	_, err = s.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: "7-0-0", Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: anns}))
130	require.NoError(t, err)
131	_, err = s.UploadSummary(ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: "7-0-0", StepNumber: 3, Markdown: "# md"}))
132	require.NoError(t, err)
133	_, err = s.UploadSummary(ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: "7-0-0", StepNumber: 3}))
134	requireCode(t, connect.CodeFailedPrecondition, err)
135
136	_, err = s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: []*pb.Annotation{{Message: "no level", StepNumber: 1}}}))
137	requireCode(t, connect.CodeInvalidArgument, err)
138	_, err = s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: []*pb.Annotation{{Level: 99, Message: "x", StepNumber: 1}}}))
139	requireCode(t, connect.CodeInvalidArgument, err)
140	_, err = s.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: "7-0-0", Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{{Message: "x"}}}))
141	requireCode(t, connect.CodeInvalidArgument, err)
142}
143
144// StepUpdate.outcome reaches the use case, and Step.outcome is the conclusion
145// for a completed step stored without one, and unset before the end.
146func TestStepOutcomeMapping(t *testing.T) {
147	t.Parallel()
148	ups, err := stepUpdatesFromPB([]*pb.StepUpdate{{Number: 1, State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE}})
149	require.NoError(t, err)
150	assert.Equal(t, domain.CIFailure, ups[0].Outcome)
151	_, err = stepUpdatesFromPB([]*pb.StepUpdate{{Number: 1, Outcome: 99}})
152	assert.Error(t, err)
153
154	steps := stepsToPB([]domain.CIStep{
155		{Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, Outcome: domain.CIFailure},
156		{Number: 2, State: domain.CIStepCompleted, Conclusion: domain.CIFailure},
157		{Number: 3, State: domain.CIStepInProgress},
158		{Number: 4, State: domain.CIStepCompleted, Conclusion: domain.CISkipped, Outcome: domain.CISkipped},
159	}, 0)
160	got := make([]pb.CiConclusion, len(steps))
161	for i, s := range steps {
162		got[i] = s.GetOutcome()
163	}
164	assert.Equal(t, []pb.CiConclusion{
165		pb.CiConclusion_CI_CONCLUSION_FAILURE, pb.CiConclusion_CI_CONCLUSION_FAILURE,
166		pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED, pb.CiConclusion_CI_CONCLUSION_SKIPPED,
167	}, got)
168}
169
170func TestRunServiceAnnotationsAndSummaries(t *testing.T) {
171	t.Parallel()
172	repo := mustRepoName(t, "demo")
173	runs := mocks.NewRunService(t)
174	runs.EXPECT().ListAnnotations(mock.Anything, domain.Owner(), repo, int64(7), int32(2), "7-0-0").Return(ports.AnnotationList{
175		Annotations: []ports.JobAnnotation{{JobID: "7-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 4, Step: 3}}},
176		Truncated:   true,
177	}, nil).Once()
178	runs.EXPECT().GetStepSummaries(mock.Anything, domain.Owner(), repo, "7-0-0", int32(0), int64(0)).Return(ports.SummaryList{
179		Summaries: []domain.CIStepSummary{{Step: 2, Markdown: "# s"}}, Truncated: true,
180	}, nil).Once()
181	runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(8), int32(0), "").Return(ports.AnnotationList{}, domain.ErrNotFound).Once()
182	s := &runService{runs: runs}
183
184	res, err := s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "demo", RunId: 7, Attempt: 2, JobId: "7-0-0"}))
185	require.NoError(t, err)
186	require.Len(t, res.Msg.GetAnnotations(), 1)
187	a := res.Msg.GetAnnotations()[0]
188	assert.Equal(t, "7-0-0", a.GetJobId())
189	assert.Equal(t, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, a.GetAnnotation().GetLevel())
190	assert.EqualValues(t, 3, a.GetAnnotation().GetStepNumber())
191	assert.EqualValues(t, 4, a.GetAnnotation().GetStartLine())
192	assert.True(t, res.Msg.GetTruncated())
193
194	sums, err := s.GetStepSummaries(ownerCtx(), connect.NewRequest(&pb.GetStepSummariesRequest{Repo: "demo", JobId: "7-0-0"}))
195	require.NoError(t, err)
196	require.Len(t, sums.Msg.GetSummaries(), 1)
197	assert.EqualValues(t, 2, sums.Msg.GetSummaries()[0].GetStepNumber())
198	assert.Equal(t, "# s", sums.Msg.GetSummaries()[0].GetMarkdown())
199	assert.True(t, sums.Msg.GetTruncated())
200
201	_, err = s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "demo", RunId: 8}))
202	requireCode(t, connect.CodeNotFound, err)
203	_, err = s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "../x", RunId: 8}))
204	requireCode(t, connect.CodeInvalidArgument, err)
205	_, err = s.GetStepSummaries(ownerCtx(), connect.NewRequest(&pb.GetStepSummariesRequest{Repo: "../x"}))
206	requireCode(t, connect.CodeInvalidArgument, err)
207}
208
209// UploadSummary is a job call: its bearer secret is the job token, not a stored
210// token, and a summary of exactly 1 MiB passes the size limit of the runner side.
211func TestUploadSummaryIsAJobCallWithRoomForOneMiB(t *testing.T) {
212	t.Parallel()
213	assert.True(t, runnerSecretProcedures[ermiteconnect.RunnerServiceUploadSummaryProcedure])
214	assert.Greater(t, RunnerMaxRequestBytes, 1<<20+len("7-0-0")+16, "1 MiB plus the envelope")
215	assert.Equal(t, MaxRequestBytes, 1<<20, "the other services keep the 1 MiB limit")
216	assert.Contains(t, Features, "ci.annotations")
217	assert.Contains(t, Features, "ci.summaries")
218}

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