detached bdc41cd

fix(host): show the server's reason when resolving actions fails (#40)

commit
bdc41cd2ab0469431db322c9bbb6b83576e5e0fb
author
brice · · 2026-10-17 20:01 UTC
committer
bnema · · 2026-10-17 20:01 UTC
parent
257b319
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.4.3
2 files · +52 -2
M internal/host/resolve.go +16 -2
@@ -5,6 +5,7 @@ package host
55 import (
66 "context"
77 "encoding/json"
8+ "errors"
89 "fmt"
910 "strings"
1011 "unicode/utf8"
@@ -213,15 +214,28 @@ func (j *jobRun) callResolve(ctx context.Context, uses []string) ([]*execpb.Reso
213214 j.abandon(err)
214215 return fail("the server ended the job")
215216 case kindPermanent:
216- return fail("resolving actions failed: %s", connect.CodeOf(err))
217+ return fail("resolving actions failed: %s", serverReason(err))
217218 }
218219 j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
219220 if attempt >= 3 || !sleep(ctx, b.next()) {
220- return fail("resolving actions failed: %s", connect.CodeOf(err))
221+ return fail("resolving actions failed: %s", serverReason(err))
221222 }
222223 }
223224 }
224225
226+// serverReason is the code of a failed call, followed by the message of the
227+// server when it gave one, so the job log names what could not be resolved.
228+// Only a message the server sent goes in the job log, which the readers of the
229+// repository see: a failure of the runner's own connection (DNS, TLS, an
230+// address) stays in the runner's log.
231+func serverReason(err error) string {
232+ var ce *connect.Error
233+ if errors.As(err, &ce) && connect.IsWireError(ce) && ce.Message() != "" {
234+ return fmt.Sprintf("%s: %s", ce.Code(), ce.Message())
235+ }
236+ return connect.CodeOf(err).String()
237+}
238+
225239 // resolveResponse converts the answer of the server. The token of an archive is
226240 // a secret that the sandbox sends in an Authorization header, so it only goes
227241 // with an archive of the ermite source whose tarball URL is on an origin this
@@ -5,6 +5,7 @@ package host
5 import (5 import (
6 "context"6 "context"
7 "encoding/json"7 "encoding/json"
8+ "errors"
8 "fmt"9 "fmt"
9 "strings"10 "strings"
10 "unicode/utf8"11 "unicode/utf8"
@@ -213,15 +214,28 @@ func (j *jobRun) callResolve(ctx context.Context, uses []string) ([]*execpb.Reso
213 j.abandon(err)214 j.abandon(err)
214 return fail("the server ended the job")215 return fail("the server ended the job")
215 case kindPermanent:216 case kindPermanent:
216- return fail("resolving actions failed: %s", connect.CodeOf(err))217+ return fail("resolving actions failed: %s", serverReason(err))
217 }218 }
218 j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")219 j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
219 if attempt >= 3 || !sleep(ctx, b.next()) {220 if attempt >= 3 || !sleep(ctx, b.next()) {
220- return fail("resolving actions failed: %s", connect.CodeOf(err))221+ return fail("resolving actions failed: %s", serverReason(err))
221 }222 }
222 }223 }
223 }224 }
224 225
226+// serverReason is the code of a failed call, followed by the message of the
227+// server when it gave one, so the job log names what could not be resolved.
228+// Only a message the server sent goes in the job log, which the readers of the
229+// repository see: a failure of the runner's own connection (DNS, TLS, an
230+// address) stays in the runner's log.
231+func serverReason(err error) string {
232+ var ce *connect.Error
233+ if errors.As(err, &ce) && connect.IsWireError(ce) && ce.Message() != "" {
234+ return fmt.Sprintf("%s: %s", ce.Code(), ce.Message())
235+ }
236+ return connect.CodeOf(err).String()
237+}
238+
225 // resolveResponse converts the answer of the server. The token of an archive is239 // resolveResponse converts the answer of the server. The token of an archive is
226 // a secret that the sandbox sends in an Authorization header, so it only goes240 // a secret that the sandbox sends in an Authorization header, so it only goes
227 // with an archive of the ermite source whose tarball URL is on an origin this241 // with an archive of the ermite source whose tarball URL is on an origin this
M internal/host/resolve_test.go +36 -0
@@ -5,7 +5,10 @@ package host
55 import (
66 "context"
77 "encoding/json"
8+ "errors"
89 "fmt"
10+ "net/http"
11+ "net/http/httptest"
912 "strings"
1013 "testing"
1114 "time"
@@ -14,6 +17,7 @@ import (
1417 "github.com/bnema/zerowrap"
1518
1619 ermite "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1721
1822 "bnema.dev/ermite-runner/internal/execstream/execpb"
1923 )
@@ -303,3 +307,35 @@ func TestResolveResponseTokenOriginIPv6(t *testing.T) {
303307 }
304308 }
305309 }
310+
311+type unavailableServer struct {
312+ ermiteconnect.UnimplementedRunnerServiceHandler
313+}
314+
315+func (unavailableServer) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
316+ return nil, connect.NewError(connect.CodeUnavailable, errors.New("resolving `actions/checkout@v4`: github.com did not answer"))
317+}
318+
319+// The job log gives the reason the server sent, not the runner's own
320+// connection failures.
321+func TestResolveActionsServerReason(t *testing.T) {
322+ path, h := ermiteconnect.NewRunnerServiceHandler(unavailableServer{})
323+ mux := http.NewServeMux()
324+ mux.Handle(path, h)
325+ srv := httptest.NewServer(mux)
326+ t.Cleanup(srv.Close)
327+ uses := &execpb.ResolveRequest{Uses: []string{"actions/checkout@v4"}}
328+
329+ r := resolveJob(ermiteconnect.NewRunnerServiceClient(srv.Client(), srv.URL)).resolveActions(context.Background(), uses)
330+ if want := "resolving actions failed: unavailable: resolving `actions/checkout@v4`: github.com did not answer"; r.Error != want {
331+ t.Errorf("the job log must name the action: %q", r.Error)
332+ }
333+
334+ closed := httptest.NewServer(mux)
335+ url := closed.URL
336+ closed.Close()
337+ r = resolveJob(ermiteconnect.NewRunnerServiceClient(http.DefaultClient, url)).resolveActions(context.Background(), uses)
338+ if r.Error != "resolving actions failed: unavailable" {
339+ t.Errorf("a connection failure of the runner stays out of the job log: %q", r.Error)
340+ }
341+}
@@ -5,7 +5,10 @@ package host
5 import (5 import (
6 "context"6 "context"
7 "encoding/json"7 "encoding/json"
8+ "errors"
8 "fmt"9 "fmt"
10+ "net/http"
11+ "net/http/httptest"
9 "strings"12 "strings"
10 "testing"13 "testing"
11 "time"14 "time"
@@ -14,6 +17,7 @@ import (
14 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
15 18
16 ermite "bnema.dev/ermite-api/gen/ermite"19 ermite "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17 21
18 "bnema.dev/ermite-runner/internal/execstream/execpb"22 "bnema.dev/ermite-runner/internal/execstream/execpb"
19 )23 )
@@ -303,3 +307,35 @@ func TestResolveResponseTokenOriginIPv6(t *testing.T) {
303 }307 }
304 }308 }
305 }309 }
310+
311+type unavailableServer struct {
312+ ermiteconnect.UnimplementedRunnerServiceHandler
313+}
314+
315+func (unavailableServer) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
316+ return nil, connect.NewError(connect.CodeUnavailable, errors.New("resolving `actions/checkout@v4`: github.com did not answer"))
317+}
318+
319+// The job log gives the reason the server sent, not the runner's own
320+// connection failures.
321+func TestResolveActionsServerReason(t *testing.T) {
322+ path, h := ermiteconnect.NewRunnerServiceHandler(unavailableServer{})
323+ mux := http.NewServeMux()
324+ mux.Handle(path, h)
325+ srv := httptest.NewServer(mux)
326+ t.Cleanup(srv.Close)
327+ uses := &execpb.ResolveRequest{Uses: []string{"actions/checkout@v4"}}
328+
329+ r := resolveJob(ermiteconnect.NewRunnerServiceClient(srv.Client(), srv.URL)).resolveActions(context.Background(), uses)
330+ if want := "resolving actions failed: unavailable: resolving `actions/checkout@v4`: github.com did not answer"; r.Error != want {
331+ t.Errorf("the job log must name the action: %q", r.Error)
332+ }
333+
334+ closed := httptest.NewServer(mux)
335+ url := closed.URL
336+ closed.Close()
337+ r = resolveJob(ermiteconnect.NewRunnerServiceClient(http.DefaultClient, url)).resolveActions(context.Background(), uses)
338+ if r.Error != "resolving actions failed: unavailable" {
339+ t.Errorf("a connection failure of the runner stays out of the job log: %q", r.Error)
340+ }
341+}

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 4

Tags 11

↑↓ moveenter openesc close