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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "encoding/json" | |
| 8 | + | "errors" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "unicode/utf8" | |
| @@ -213,15 +214,28 @@ func (j *jobRun) callResolve(ctx context.Context, uses []string) ([]*execpb.Reso | |||
| 213 | 214 | j.abandon(err) | |
| 214 | 215 | return fail("the server ended the job") | |
| 215 | 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 | 219 | j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed") | |
| 219 | 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 | 239 | // resolveResponse converts the answer of the server. The token of an archive is | |
| 226 | 240 | // a secret that the sandbox sends in an Authorization header, so it only goes | |
| 227 | 241 | // 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 is | 239 | // 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 goes | 240 | // 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 this | 241 | // 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "encoding/json" | |
| 8 | + | "errors" | |
| 8 | 9 | "fmt" | |
| 10 | + | "net/http" | |
| 11 | + | "net/http/httptest" | |
| 9 | 12 | "strings" | |
| 10 | 13 | "testing" | |
| 11 | 14 | "time" | |
| @@ -14,6 +17,7 @@ import ( | |||
| 14 | 17 | "github.com/bnema/zerowrap" | |
| 15 | 18 | ||
| 16 | 19 | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 20 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 17 | 21 | ||
| 18 | 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 | + | } | |
| @@ -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 | + | } | |||