feat(host): resolve actions for the executor and give the job token to github.token
- commit
- a6671fa0b094e8c5e44fd3f4f42897f03900c42c
- author
- brice · · 2026-10-13 15:16 UTC
- parent
- 1396410
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +803 -36
M internal/execstream/exec.proto +32 -0
| @@ -25,6 +25,8 @@ message Frame { | |||
| 25 | 25 | StepCompleted step_completed = 7; // executor -> host | |
| 26 | 26 | Log log = 8; // executor -> host | |
| 27 | 27 | JobResult job_result = 9; // executor -> host, before Exit | |
| 28 | + | ResolveRequest resolve_request = 10; // executor -> host | |
| 29 | + | ResolveResponse resolve_response = 11; // host -> executor, answers a ResolveRequest | |
| 28 | 30 | } | |
| 29 | 31 | } | |
| 30 | 32 | ||
| @@ -145,3 +147,33 @@ message JobResult { | |||
| 145 | 147 | string error = 3; | |
| 146 | 148 | bool runner_shutdown = 4; | |
| 147 | 149 | } | |
| 150 | + | ||
| 151 | + | // ResolveRequest asks the host to resolve actions with RunnerService. | |
| 152 | + | // ResolveActions: the job token stays in the host, so the executor cannot call | |
| 153 | + | // it. The executor sends one per level of composite actions (the runner's own | |
| 154 | + | // "one more round per level") and waits for the ResolveResponse with the same id. | |
| 155 | + | message ResolveRequest { | |
| 156 | + | int64 id = 1; | |
| 157 | + | repeated string uses = 2; // "owner/repo[/path]@ref", at most 100 | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // ResolveResponse answers a ResolveRequest. error is set when the call itself | |
| 161 | + | // failed (the executor fails the job); actions has one entry per uses otherwise. | |
| 162 | + | message ResolveResponse { | |
| 163 | + | int64 id = 1; | |
| 164 | + | string error = 2; | |
| 165 | + | repeated ResolvedAction actions = 3; | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // ResolvedAction mirrors ermite.ResolvedAction (the token is a secret: the | |
| 169 | + | // executor masks it). | |
| 170 | + | message ResolvedAction { | |
| 171 | + | string ref = 1; | |
| 172 | + | string name_with_owner = 2; | |
| 173 | + | string path = 3; | |
| 174 | + | string resolved_sha = 4; | |
| 175 | + | string tarball_url = 5; | |
| 176 | + | string token = 6; | |
| 177 | + | string source = 7; | |
| 178 | + | string error = 8; | |
| 179 | + | } | |
| @@ -25,6 +25,8 @@ message Frame { | |||||
| 25 | StepCompleted step_completed = 7; // executor -> host | 25 | StepCompleted step_completed = 7; // executor -> host | ||
| 26 | Log log = 8; // executor -> host | 26 | Log log = 8; // executor -> host | ||
| 27 | JobResult job_result = 9; // executor -> host, before Exit | 27 | JobResult job_result = 9; // executor -> host, before Exit | ||
| 28 | + | ResolveRequest resolve_request = 10; // executor -> host | |||
| 29 | + | ResolveResponse resolve_response = 11; // host -> executor, answers a ResolveRequest | |||
| 28 | } | 30 | } | ||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| @@ -145,3 +147,33 @@ message JobResult { | |||||
| 145 | string error = 3; | 147 | string error = 3; | ||
| 146 | bool runner_shutdown = 4; | 148 | bool runner_shutdown = 4; | ||
| 147 | } | 149 | } | ||
| 150 | + | ||||
| 151 | + | // ResolveRequest asks the host to resolve actions with RunnerService. | |||
| 152 | + | // ResolveActions: the job token stays in the host, so the executor cannot call | |||
| 153 | + | // it. The executor sends one per level of composite actions (the runner's own | |||
| 154 | + | // "one more round per level") and waits for the ResolveResponse with the same id. | |||
| 155 | + | message ResolveRequest { | |||
| 156 | + | int64 id = 1; | |||
| 157 | + | repeated string uses = 2; // "owner/repo[/path]@ref", at most 100 | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // ResolveResponse answers a ResolveRequest. error is set when the call itself | |||
| 161 | + | // failed (the executor fails the job); actions has one entry per uses otherwise. | |||
| 162 | + | message ResolveResponse { | |||
| 163 | + | int64 id = 1; | |||
| 164 | + | string error = 2; | |||
| 165 | + | repeated ResolvedAction actions = 3; | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // ResolvedAction mirrors ermite.ResolvedAction (the token is a secret: the | |||
| 169 | + | // executor masks it). | |||
| 170 | + | message ResolvedAction { | |||
| 171 | + | string ref = 1; | |||
| 172 | + | string name_with_owner = 2; | |||
| 173 | + | string path = 3; | |||
| 174 | + | string resolved_sha = 4; | |||
| 175 | + | string tarball_url = 5; | |||
| 176 | + | string token = 6; | |||
| 177 | + | string source = 7; | |||
| 178 | + | string error = 8; | |||
| 179 | + | } | |||
M internal/execstream/execpb/exec.pb.go +313 -33 generated file: collapsed
M internal/execstream/frame.go +14 -0
| @@ -115,6 +115,10 @@ type JobHandler struct { | |||
| 115 | 115 | StepCompleted func(*execpb.StepCompleted) | |
| 116 | 116 | Log func(*execpb.Log) | |
| 117 | 117 | JobResult func(*execpb.JobResult) | |
| 118 | + | // Resolve answers the executor's ResolveRequest frames. It is called in its | |
| 119 | + | // own goroutine (the call goes to the server), the answer is sent as a | |
| 120 | + | // ResolveResponse frame carrying the id of the request. | |
| 121 | + | Resolve func(*execpb.ResolveRequest) *execpb.ResolveResponse | |
| 118 | 122 | } | |
| 119 | 123 | ||
| 120 | 124 | // DriveJob is Drive for a session that carries a job: besides Output frames it | |
| @@ -179,6 +183,16 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h | |||
| 179 | 183 | if h.JobResult != nil { | |
| 180 | 184 | h.JobResult(b.JobResult) | |
| 181 | 185 | } | |
| 186 | + | case *execpb.Frame_ResolveRequest: | |
| 187 | + | req := b.ResolveRequest | |
| 188 | + | go func() { | |
| 189 | + | resp := &execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"} | |
| 190 | + | if h.Resolve != nil { | |
| 191 | + | resp = h.Resolve(req) | |
| 192 | + | resp.Id = req.Id | |
| 193 | + | } | |
| 194 | + | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: resp}}) | |
| 195 | + | }() | |
| 182 | 196 | case *execpb.Frame_Exit: | |
| 183 | 197 | return hello, b.Exit, nil | |
| 184 | 198 | } | |
| @@ -115,6 +115,10 @@ type JobHandler struct { | |||||
| 115 | StepCompleted func(*execpb.StepCompleted) | 115 | StepCompleted func(*execpb.StepCompleted) | ||
| 116 | Log func(*execpb.Log) | 116 | Log func(*execpb.Log) | ||
| 117 | JobResult func(*execpb.JobResult) | 117 | JobResult func(*execpb.JobResult) | ||
| 118 | + | // Resolve answers the executor's ResolveRequest frames. It is called in its | |||
| 119 | + | // own goroutine (the call goes to the server), the answer is sent as a | |||
| 120 | + | // ResolveResponse frame carrying the id of the request. | |||
| 121 | + | Resolve func(*execpb.ResolveRequest) *execpb.ResolveResponse | |||
| 118 | } | 122 | } | ||
| 119 | 123 | ||||
| 120 | // DriveJob is Drive for a session that carries a job: besides Output frames it | 124 | // DriveJob is Drive for a session that carries a job: besides Output frames it | ||
| @@ -179,6 +183,16 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h | |||||
| 179 | if h.JobResult != nil { | 183 | if h.JobResult != nil { | ||
| 180 | h.JobResult(b.JobResult) | 184 | h.JobResult(b.JobResult) | ||
| 181 | } | 185 | } | ||
| 186 | + | case *execpb.Frame_ResolveRequest: | |||
| 187 | + | req := b.ResolveRequest | |||
| 188 | + | go func() { | |||
| 189 | + | resp := &execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"} | |||
| 190 | + | if h.Resolve != nil { | |||
| 191 | + | resp = h.Resolve(req) | |||
| 192 | + | resp.Id = req.Id | |||
| 193 | + | } | |||
| 194 | + | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: resp}}) | |||
| 195 | + | }() | |||
| 182 | case *execpb.Frame_Exit: | 196 | case *execpb.Frame_Exit: | ||
| 183 | return hello, b.Exit, nil | 197 | return hello, b.Exit, nil | ||
| 184 | } | 198 | } | ||
A internal/executor/actions_types.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-actions/action" | |
| 11 | + | "bnema.dev/ermite-actions/workflow" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ResolvedAction is where to download one action (ermite.ResolvedAction, see | |
| 15 | + | // the host). The Token is a secret: the executor masks it. | |
| 16 | + | type ResolvedAction struct { | |
| 17 | + | Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It | |
| 21 | + | // is the host's call (the job token stays out of the sandbox); the result has | |
| 22 | + | // one entry per value, in order. | |
| 23 | + | type Resolver func(ctx context.Context, uses []string) ([]ResolvedAction, error) | |
| 24 | + | ||
| 25 | + | // ActionsConfig is what the executor needs to run actions. | |
| 26 | + | type ActionsConfig struct { | |
| 27 | + | // Resolver is required to run actions from repositories; without it a job | |
| 28 | + | // that uses one fails "Set up job". | |
| 29 | + | Resolver Resolver | |
| 30 | + | // HTTPClient downloads the archives; nil means a client with the default | |
| 31 | + | // transport. It must not follow a redirect to another host with the | |
| 32 | + | // credentials (net/http does not). | |
| 33 | + | HTTPClient *http.Client | |
| 34 | + | // ExternalsDir is where Node.js lives (<dir>/node20/bin/node); empty means | |
| 35 | + | // /home/runner/externals. | |
| 36 | + | ExternalsDir string | |
| 37 | + | // DockerPath is the docker CLI; empty means the one found in PATH. | |
| 38 | + | DockerPath string | |
| 39 | + | // Node24ByDefault is the server flag actions.runner.usenode24bydefault | |
| 40 | + | // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no | |
| 41 | + | // feature flags, so the default is pinned here. | |
| 42 | + | Node24ByDefault bool | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | const defaultExternalsDir = "/home/runner/externals" | |
| 46 | + | ||
| 47 | + | // stage is ActionRunStage. | |
| 48 | + | type stage int | |
| 49 | + | ||
| 50 | + | const ( | |
| 51 | + | stageMain stage = iota | |
| 52 | + | stagePre | |
| 53 | + | stagePost | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | // compositeMaxDepth is Constants.CompositeActionsMaxDepth | |
| 57 | + | // (Runner.Common/Constants.cs:46). | |
| 58 | + | const compositeMaxDepth = 9 | |
| 59 | + | ||
| 60 | + | // actionDef is a loaded action: its directory and manifest (ActionManager. | |
| 61 | + | // LoadAction's Definition). A docker:// step has no manifest and no directory. | |
| 62 | + | type actionDef struct { | |
| 63 | + | uses action.Uses | |
| 64 | + | // ref is "Name[/Path]@Ref" for repository actions, the path for local ones. | |
| 65 | + | ref string | |
| 66 | + | dir string | |
| 67 | + | act *action.Action // nil for docker:// and Dockerfile-only actions | |
| 68 | + | // image is what a container action runs: "docker://x" steps and docker:// | |
| 69 | + | // manifests give the image to pull, Dockerfile ones the path of the file. | |
| 70 | + | image string | |
| 71 | + | dockerfile bool | |
| 72 | + | // children are the loaded actions of a composite's steps, by step. | |
| 73 | + | children map[*workflow.Step]*actionDef | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (d *actionDef) runs() *action.Runs { | |
| 77 | + | if d.act == nil { | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | return d.act.Runs | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // hasPre and hasPost are ActionExecutionData.HasPre/HasPost. | |
| 84 | + | func (d *actionDef) hasPre() bool { | |
| 85 | + | r := d.runs() | |
| 86 | + | switch { | |
| 87 | + | case r == nil: | |
| 88 | + | return false | |
| 89 | + | case r.Kind == action.RunsComposite: | |
| 90 | + | return d.anyChild(func(c *actionDef) bool { return c.hasPre() && !c.local() }) | |
| 91 | + | } | |
| 92 | + | return r.HasPre() | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (d *actionDef) hasPost() bool { | |
| 96 | + | r := d.runs() | |
| 97 | + | switch { | |
| 98 | + | case r == nil: | |
| 99 | + | return false | |
| 100 | + | case r.Kind == action.RunsComposite: | |
| 101 | + | return d.anyChild(func(c *actionDef) bool { return c.local() || c.hasPost() }) | |
| 102 | + | } | |
| 103 | + | return r.HasPost() | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (d *actionDef) anyChild(f func(*actionDef) bool) bool { | |
| 107 | + | for _, c := range d.children { | |
| 108 | + | if c != nil && f(c) { | |
| 109 | + | return true | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (d *actionDef) local() bool { return d.uses.Kind == action.UsesLocal } | |
| 116 | + | ||
| 117 | + | // preIf and postIf are InitCondition and CleanupCondition: always() unless the | |
| 118 | + | // manifest says otherwise (action.Runs keeps them as written). | |
| 119 | + | func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) } | |
| 120 | + | func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) } | |
| 121 | + | ||
| 122 | + | func condOrAlways(r *action.Runs, pre bool) string { | |
| 123 | + | if r == nil || r.Kind == action.RunsComposite { | |
| 124 | + | return "always()" | |
| 125 | + | } | |
| 126 | + | c := r.PostIf | |
| 127 | + | if pre { | |
| 128 | + | c = r.PreIf | |
| 129 | + | } | |
| 130 | + | if strings.TrimSpace(c) == "" { | |
| 131 | + | return "always()" | |
| 132 | + | } | |
| 133 | + | return c | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite-actions/action" | |||
| 11 | + | "bnema.dev/ermite-actions/workflow" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ResolvedAction is where to download one action (ermite.ResolvedAction, see | |||
| 15 | + | // the host). The Token is a secret: the executor masks it. | |||
| 16 | + | type ResolvedAction struct { | |||
| 17 | + | Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It | |||
| 21 | + | // is the host's call (the job token stays out of the sandbox); the result has | |||
| 22 | + | // one entry per value, in order. | |||
| 23 | + | type Resolver func(ctx context.Context, uses []string) ([]ResolvedAction, error) | |||
| 24 | + | ||||
| 25 | + | // ActionsConfig is what the executor needs to run actions. | |||
| 26 | + | type ActionsConfig struct { | |||
| 27 | + | // Resolver is required to run actions from repositories; without it a job | |||
| 28 | + | // that uses one fails "Set up job". | |||
| 29 | + | Resolver Resolver | |||
| 30 | + | // HTTPClient downloads the archives; nil means a client with the default | |||
| 31 | + | // transport. It must not follow a redirect to another host with the | |||
| 32 | + | // credentials (net/http does not). | |||
| 33 | + | HTTPClient *http.Client | |||
| 34 | + | // ExternalsDir is where Node.js lives (<dir>/node20/bin/node); empty means | |||
| 35 | + | // /home/runner/externals. | |||
| 36 | + | ExternalsDir string | |||
| 37 | + | // DockerPath is the docker CLI; empty means the one found in PATH. | |||
| 38 | + | DockerPath string | |||
| 39 | + | // Node24ByDefault is the server flag actions.runner.usenode24bydefault | |||
| 40 | + | // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no | |||
| 41 | + | // feature flags, so the default is pinned here. | |||
| 42 | + | Node24ByDefault bool | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | const defaultExternalsDir = "/home/runner/externals" | |||
| 46 | + | ||||
| 47 | + | // stage is ActionRunStage. | |||
| 48 | + | type stage int | |||
| 49 | + | ||||
| 50 | + | const ( | |||
| 51 | + | stageMain stage = iota | |||
| 52 | + | stagePre | |||
| 53 | + | stagePost | |||
| 54 | + | ) | |||
| 55 | + | ||||
| 56 | + | // compositeMaxDepth is Constants.CompositeActionsMaxDepth | |||
| 57 | + | // (Runner.Common/Constants.cs:46). | |||
| 58 | + | const compositeMaxDepth = 9 | |||
| 59 | + | ||||
| 60 | + | // actionDef is a loaded action: its directory and manifest (ActionManager. | |||
| 61 | + | // LoadAction's Definition). A docker:// step has no manifest and no directory. | |||
| 62 | + | type actionDef struct { | |||
| 63 | + | uses action.Uses | |||
| 64 | + | // ref is "Name[/Path]@Ref" for repository actions, the path for local ones. | |||
| 65 | + | ref string | |||
| 66 | + | dir string | |||
| 67 | + | act *action.Action // nil for docker:// and Dockerfile-only actions | |||
| 68 | + | // image is what a container action runs: "docker://x" steps and docker:// | |||
| 69 | + | // manifests give the image to pull, Dockerfile ones the path of the file. | |||
| 70 | + | image string | |||
| 71 | + | dockerfile bool | |||
| 72 | + | // children are the loaded actions of a composite's steps, by step. | |||
| 73 | + | children map[*workflow.Step]*actionDef | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (d *actionDef) runs() *action.Runs { | |||
| 77 | + | if d.act == nil { | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | return d.act.Runs | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // hasPre and hasPost are ActionExecutionData.HasPre/HasPost. | |||
| 84 | + | func (d *actionDef) hasPre() bool { | |||
| 85 | + | r := d.runs() | |||
| 86 | + | switch { | |||
| 87 | + | case r == nil: | |||
| 88 | + | return false | |||
| 89 | + | case r.Kind == action.RunsComposite: | |||
| 90 | + | return d.anyChild(func(c *actionDef) bool { return c.hasPre() && !c.local() }) | |||
| 91 | + | } | |||
| 92 | + | return r.HasPre() | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (d *actionDef) hasPost() bool { | |||
| 96 | + | r := d.runs() | |||
| 97 | + | switch { | |||
| 98 | + | case r == nil: | |||
| 99 | + | return false | |||
| 100 | + | case r.Kind == action.RunsComposite: | |||
| 101 | + | return d.anyChild(func(c *actionDef) bool { return c.local() || c.hasPost() }) | |||
| 102 | + | } | |||
| 103 | + | return r.HasPost() | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (d *actionDef) anyChild(f func(*actionDef) bool) bool { | |||
| 107 | + | for _, c := range d.children { | |||
| 108 | + | if c != nil && f(c) { | |||
| 109 | + | return true | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (d *actionDef) local() bool { return d.uses.Kind == action.UsesLocal } | |||
| 116 | + | ||||
| 117 | + | // preIf and postIf are InitCondition and CleanupCondition: always() unless the | |||
| 118 | + | // manifest says otherwise (action.Runs keeps them as written). | |||
| 119 | + | func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) } | |||
| 120 | + | func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) } | |||
| 121 | + | ||||
| 122 | + | func condOrAlways(r *action.Runs, pre bool) string { | |||
| 123 | + | if r == nil || r.Kind == action.RunsComposite { | |||
| 124 | + | return "always()" | |||
| 125 | + | } | |||
| 126 | + | c := r.PostIf | |||
| 127 | + | if pre { | |||
| 128 | + | c = r.PreIf | |||
| 129 | + | } | |||
| 130 | + | if strings.TrimSpace(c) == "" { | |||
| 131 | + | return "always()" | |||
| 132 | + | } | |||
| 133 | + | return c | |||
| 134 | + | } | |||
M internal/executor/contexts.go +3 -0
| @@ -130,6 +130,8 @@ type Layout struct { | |||
| 130 | 130 | RunnerWorkspace string | |
| 131 | 131 | // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>. | |
| 132 | 132 | Workspace string | |
| 133 | + | // Actions is where the actions of the job are downloaded: <WorkRoot>/_actions. | |
| 134 | + | Actions string | |
| 133 | 135 | } | |
| 134 | 136 | ||
| 135 | 137 | // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image. | |
| @@ -145,6 +147,7 @@ func NewLayout(home, repo, toolCache string) Layout { | |||
| 145 | 147 | return Layout{ | |
| 146 | 148 | Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache, | |
| 147 | 149 | RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo), | |
| 150 | + | Actions: filepath.Join(work, "_actions"), | |
| 148 | 151 | } | |
| 149 | 152 | } | |
| 150 | 153 | ||
| @@ -130,6 +130,8 @@ type Layout struct { | |||||
| 130 | RunnerWorkspace string | 130 | RunnerWorkspace string | ||
| 131 | // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>. | 131 | // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>. | ||
| 132 | Workspace string | 132 | Workspace string | ||
| 133 | + | // Actions is where the actions of the job are downloaded: <WorkRoot>/_actions. | |||
| 134 | + | Actions string | |||
| 133 | } | 135 | } | ||
| 134 | 136 | ||||
| 135 | // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image. | 137 | // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image. | ||
| @@ -145,6 +147,7 @@ func NewLayout(home, repo, toolCache string) Layout { | |||||
| 145 | return Layout{ | 147 | return Layout{ | ||
| 146 | Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache, | 148 | Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache, | ||
| 147 | RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo), | 149 | RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo), | ||
| 150 | + | Actions: filepath.Join(work, "_actions"), | |||
| 148 | } | 151 | } | ||
| 149 | } | 152 | } | ||
| 150 | 153 | ||||
M internal/executor/job.go +3 -0
| @@ -75,6 +75,9 @@ type Config struct { | |||
| 75 | 75 | BaseEnv []string | |
| 76 | 76 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | |
| 77 | 77 | Debug bool | |
| 78 | + | // Actions configures the actions of the job (resolution, downloads, Node.js, | |
| 79 | + | // docker). | |
| 80 | + | Actions ActionsConfig | |
| 78 | 81 | ||
| 79 | 82 | Reporter Reporter | |
| 80 | 83 | // Log receives the runner's own diagnostics (not the job log); the zero value | |
| @@ -75,6 +75,9 @@ type Config struct { | |||||
| 75 | BaseEnv []string | 75 | BaseEnv []string | ||
| 76 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | 76 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | ||
| 77 | Debug bool | 77 | Debug bool | ||
| 78 | + | // Actions configures the actions of the job (resolution, downloads, Node.js, | |||
| 79 | + | // docker). | |||
| 80 | + | Actions ActionsConfig | |||
| 78 | 81 | ||||
| 79 | Reporter Reporter | 82 | Reporter Reporter | ||
| 80 | // Log receives the runner's own diagnostics (not the job log); the zero value | 83 | // Log receives the runner's own diagnostics (not the job log); the zero value | ||
M internal/guest/jobexec.go +7 -3
| @@ -166,6 +166,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 166 | 166 | } | |
| 167 | 167 | canceller := executor.NewCanceller() | |
| 168 | 168 | rep := &frameReporter{conn: conn, failed: make(chan struct{})} | |
| 169 | + | res := newResolver(conn) | |
| 169 | 170 | ||
| 170 | 171 | // Frames from the host while the job runs: a Cancel stops the job; a lost | |
| 171 | 172 | // host does too, like a runner shutdown. The reader ends with the stream: the | |
| @@ -183,6 +184,9 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 183 | 184 | if c := f.GetCancel(); c != nil { | |
| 184 | 185 | canceller.Cancel(cancelKind(c.GetKind())) | |
| 185 | 186 | } | |
| 187 | + | if r := f.GetResolveResponse(); r != nil { | |
| 188 | + | res.deliver(r) | |
| 189 | + | } | |
| 186 | 190 | } | |
| 187 | 191 | }() | |
| 188 | 192 | // The executor's own context (SIGTERM forwarded by init: the sandbox is | |
| @@ -199,17 +203,17 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 199 | 203 | }() | |
| 200 | 204 | ||
| 201 | 205 | env := spec.GetRunnerEnvironment() | |
| 202 | - | res := executor.Run(canceller, executor.Config{ | |
| 206 | + | result := executor.Run(canceller, executor.Config{ | |
| 203 | 207 | WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(), | |
| 204 | 208 | ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(), | |
| 205 | 209 | TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond, | |
| 206 | 210 | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | |
| 207 | 211 | RunnerVersion: spec.GetRunnerVersion(), | |
| 208 | 212 | Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential, | |
| 209 | - | Debug: spec.GetDebug(), Reporter: rep, Log: zerowrap.FromCtx(ctx), | |
| 213 | + | Debug: spec.GetDebug(), Reporter: rep, Actions: executor.ActionsConfig{Resolver: res.resolve}, Log: zerowrap.FromCtx(ctx), | |
| 210 | 214 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | |
| 211 | 215 | }) | |
| 212 | - | if res.Conclusion == executor.ConclusionSuccess { | |
| 216 | + | if result.Conclusion == executor.ConclusionSuccess { | |
| 213 | 217 | return 0 | |
| 214 | 218 | } | |
| 215 | 219 | return 1 | |
| @@ -166,6 +166,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||||
| 166 | } | 166 | } | ||
| 167 | canceller := executor.NewCanceller() | 167 | canceller := executor.NewCanceller() | ||
| 168 | rep := &frameReporter{conn: conn, failed: make(chan struct{})} | 168 | rep := &frameReporter{conn: conn, failed: make(chan struct{})} | ||
| 169 | + | res := newResolver(conn) | |||
| 169 | 170 | ||||
| 170 | // Frames from the host while the job runs: a Cancel stops the job; a lost | 171 | // Frames from the host while the job runs: a Cancel stops the job; a lost | ||
| 171 | // host does too, like a runner shutdown. The reader ends with the stream: the | 172 | // host does too, like a runner shutdown. The reader ends with the stream: the | ||
| @@ -183,6 +184,9 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||||
| 183 | if c := f.GetCancel(); c != nil { | 184 | if c := f.GetCancel(); c != nil { | ||
| 184 | canceller.Cancel(cancelKind(c.GetKind())) | 185 | canceller.Cancel(cancelKind(c.GetKind())) | ||
| 185 | } | 186 | } | ||
| 187 | + | if r := f.GetResolveResponse(); r != nil { | |||
| 188 | + | res.deliver(r) | |||
| 189 | + | } | |||
| 186 | } | 190 | } | ||
| 187 | }() | 191 | }() | ||
| 188 | // The executor's own context (SIGTERM forwarded by init: the sandbox is | 192 | // The executor's own context (SIGTERM forwarded by init: the sandbox is | ||
| @@ -199,17 +203,17 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||||
| 199 | }() | 203 | }() | ||
| 200 | 204 | ||||
| 201 | env := spec.GetRunnerEnvironment() | 205 | env := spec.GetRunnerEnvironment() | ||
| 202 | - | res := executor.Run(canceller, executor.Config{ | 206 | + | result := executor.Run(canceller, executor.Config{ |
| 203 | WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(), | 207 | WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(), | ||
| 204 | ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(), | 208 | ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(), | ||
| 205 | TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond, | 209 | TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond, | ||
| 206 | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | 210 | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | ||
| 207 | RunnerVersion: spec.GetRunnerVersion(), | 211 | RunnerVersion: spec.GetRunnerVersion(), | ||
| 208 | Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential, | 212 | Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential, | ||
| 209 | - | Debug: spec.GetDebug(), Reporter: rep, Log: zerowrap.FromCtx(ctx), | 213 | + | Debug: spec.GetDebug(), Reporter: rep, Actions: executor.ActionsConfig{Resolver: res.resolve}, Log: zerowrap.FromCtx(ctx), |
| 210 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | 214 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | ||
| 211 | }) | 215 | }) | ||
| 212 | - | if res.Conclusion == executor.ConclusionSuccess { | 216 | + | if result.Conclusion == executor.ConclusionSuccess { |
| 213 | return 0 | 217 | return 0 | ||
| 214 | } | 218 | } | ||
| 215 | return 1 | 219 | return 1 | ||
A internal/guest/resolver.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package guest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 11 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 12 | + | "bnema.dev/ermite-runner/internal/executor" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // resolver sends ResolveRequest frames to the host and matches the | |
| 16 | + | // ResolveResponse frames that come back (the job token stays in the host). | |
| 17 | + | type resolver struct { | |
| 18 | + | conn *execstream.Conn | |
| 19 | + | mu sync.Mutex | |
| 20 | + | next int64 | |
| 21 | + | wait map[int64]chan *execpb.ResolveResponse | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func newResolver(conn *execstream.Conn) *resolver { | |
| 25 | + | return &resolver{conn: conn, wait: map[int64]chan *execpb.ResolveResponse{}} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (r *resolver) deliver(resp *execpb.ResolveResponse) { | |
| 29 | + | r.mu.Lock() | |
| 30 | + | ch := r.wait[resp.Id] | |
| 31 | + | delete(r.wait, resp.Id) | |
| 32 | + | r.mu.Unlock() | |
| 33 | + | if ch != nil { | |
| 34 | + | ch <- resp | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // resolve is executor.Config.Resolver. | |
| 39 | + | func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.ResolvedAction, error) { | |
| 40 | + | ch := make(chan *execpb.ResolveResponse, 1) | |
| 41 | + | r.mu.Lock() | |
| 42 | + | r.next++ | |
| 43 | + | id := r.next | |
| 44 | + | r.wait[id] = ch | |
| 45 | + | r.mu.Unlock() | |
| 46 | + | forget := func() { r.mu.Lock(); delete(r.wait, id); r.mu.Unlock() } | |
| 47 | + | req := &execpb.ResolveRequest{Id: id, Uses: uses} | |
| 48 | + | if err := r.conn.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: req}}); err != nil { | |
| 49 | + | forget() | |
| 50 | + | return nil, err | |
| 51 | + | } | |
| 52 | + | select { | |
| 53 | + | case resp := <-ch: | |
| 54 | + | if resp.Error != "" { | |
| 55 | + | return nil, errors.New(resp.Error) | |
| 56 | + | } | |
| 57 | + | out := make([]executor.ResolvedAction, len(resp.Actions)) | |
| 58 | + | for i, a := range resp.Actions { | |
| 59 | + | out[i] = executor.ResolvedAction{ | |
| 60 | + | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha, | |
| 61 | + | TarballURL: a.TarballUrl, Token: a.Token, Error: a.Error, | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | return out, nil | |
| 65 | + | case <-ctx.Done(): | |
| 66 | + | forget() | |
| 67 | + | return nil, ctx.Err() | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package guest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "sync" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 11 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 12 | + | "bnema.dev/ermite-runner/internal/executor" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // resolver sends ResolveRequest frames to the host and matches the | |||
| 16 | + | // ResolveResponse frames that come back (the job token stays in the host). | |||
| 17 | + | type resolver struct { | |||
| 18 | + | conn *execstream.Conn | |||
| 19 | + | mu sync.Mutex | |||
| 20 | + | next int64 | |||
| 21 | + | wait map[int64]chan *execpb.ResolveResponse | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func newResolver(conn *execstream.Conn) *resolver { | |||
| 25 | + | return &resolver{conn: conn, wait: map[int64]chan *execpb.ResolveResponse{}} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (r *resolver) deliver(resp *execpb.ResolveResponse) { | |||
| 29 | + | r.mu.Lock() | |||
| 30 | + | ch := r.wait[resp.Id] | |||
| 31 | + | delete(r.wait, resp.Id) | |||
| 32 | + | r.mu.Unlock() | |||
| 33 | + | if ch != nil { | |||
| 34 | + | ch <- resp | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // resolve is executor.Config.Resolver. | |||
| 39 | + | func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.ResolvedAction, error) { | |||
| 40 | + | ch := make(chan *execpb.ResolveResponse, 1) | |||
| 41 | + | r.mu.Lock() | |||
| 42 | + | r.next++ | |||
| 43 | + | id := r.next | |||
| 44 | + | r.wait[id] = ch | |||
| 45 | + | r.mu.Unlock() | |||
| 46 | + | forget := func() { r.mu.Lock(); delete(r.wait, id); r.mu.Unlock() } | |||
| 47 | + | req := &execpb.ResolveRequest{Id: id, Uses: uses} | |||
| 48 | + | if err := r.conn.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: req}}); err != nil { | |||
| 49 | + | forget() | |||
| 50 | + | return nil, err | |||
| 51 | + | } | |||
| 52 | + | select { | |||
| 53 | + | case resp := <-ch: | |||
| 54 | + | if resp.Error != "" { | |||
| 55 | + | return nil, errors.New(resp.Error) | |||
| 56 | + | } | |||
| 57 | + | out := make([]executor.ResolvedAction, len(resp.Actions)) | |||
| 58 | + | for i, a := range resp.Actions { | |||
| 59 | + | out[i] = executor.ResolvedAction{ | |||
| 60 | + | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha, | |||
| 61 | + | TarballURL: a.TarballUrl, Token: a.Token, Error: a.Error, | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | return out, nil | |||
| 65 | + | case <-ctx.Done(): | |||
| 66 | + | forget() | |||
| 67 | + | return nil, ctx.Err() | |||
| 68 | + | } | |||
| 69 | + | } | |||
M internal/host/job.go +6 -0
| @@ -31,6 +31,7 @@ type jobClient interface { | |||
| 31 | 31 | Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) | |
| 32 | 32 | WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error) | |
| 33 | 33 | CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) | |
| 34 | + | ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | var _ jobClient = ermiteconnect.RunnerServiceClient(nil) | |
| @@ -570,6 +571,10 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct | |||
| 570 | 571 | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | |
| 571 | 572 | return failure() | |
| 572 | 573 | } | |
| 574 | + | if j.contexts, err = SetGitHubToken(j.contexts, j.job.GithubToken); err != nil { | |
| 575 | + | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | |
| 576 | + | return failure() | |
| 577 | + | } | |
| 573 | 578 | sb, err := rt.Driver.New() | |
| 574 | 579 | if err != nil { | |
| 575 | 580 | j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err)) | |
| @@ -648,6 +653,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||
| 648 | 653 | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | |
| 649 | 654 | Log: j.logLine, | |
| 650 | 655 | JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() }, | |
| 656 | + | Resolve: j.resolveActions, | |
| 651 | 657 | }) | |
| 652 | 658 | return exit, err | |
| 653 | 659 | } | |
| @@ -31,6 +31,7 @@ type jobClient interface { | |||||
| 31 | Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) | 31 | Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error) | ||
| 32 | WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error) | 32 | WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error) | ||
| 33 | CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) | 33 | CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) | ||
| 34 | + | ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) | |||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | var _ jobClient = ermiteconnect.RunnerServiceClient(nil) | 37 | var _ jobClient = ermiteconnect.RunnerServiceClient(nil) | ||
| @@ -570,6 +571,10 @@ func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct | |||||
| 570 | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | 571 | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | ||
| 571 | return failure() | 572 | return failure() | ||
| 572 | } | 573 | } | ||
| 574 | + | if j.contexts, err = SetGitHubToken(j.contexts, j.job.GithubToken); err != nil { | |||
| 575 | + | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | |||
| 576 | + | return failure() | |||
| 577 | + | } | |||
| 573 | sb, err := rt.Driver.New() | 578 | sb, err := rt.Driver.New() | ||
| 574 | if err != nil { | 579 | if err != nil { | ||
| 575 | j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err)) | 580 | j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err)) | ||
| @@ -648,6 +653,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||||
| 648 | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | 653 | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | ||
| 649 | Log: j.logLine, | 654 | Log: j.logLine, | ||
| 650 | JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() }, | 655 | JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() }, | ||
| 656 | + | Resolve: j.resolveActions, | |||
| 651 | }) | 657 | }) | ||
| 652 | return exit, err | 658 | return exit, err | ||
| 653 | } | 659 | } | ||
A internal/host/resolve.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package host | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | ||
| 12 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // maxResolveRefs is the limit of ResolveActionsRequest.refs. | |
| 18 | + | const maxResolveRefs = 100 | |
| 19 | + | ||
| 20 | + | // resolveActions answers a ResolveRequest of the executor with | |
| 21 | + | // RunnerService.ResolveActions. The job token stays here: the executor only | |
| 22 | + | // gets what the server returns (the per-archive tokens included, which it | |
| 23 | + | // masks). A call that fails without a verdict is retried like the other job | |
| 24 | + | // calls; a failure of the call itself is reported to the executor, which fails | |
| 25 | + | // "Set up job" (ActionManager: FailedToResolveActionDownloadInfoException). | |
| 26 | + | func (j *jobRun) resolveActions(req *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 27 | + | if len(req.Uses) > maxResolveRefs { | |
| 28 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)} | |
| 29 | + | } | |
| 30 | + | refs := make([]*ermite.ActionRef, len(req.Uses)) | |
| 31 | + | for i, u := range req.Uses { | |
| 32 | + | refs[i] = &ermite.ActionRef{Uses: u} | |
| 33 | + | } | |
| 34 | + | b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax} | |
| 35 | + | for attempt := 1; ; attempt++ { | |
| 36 | + | cctx, cancel := context.WithTimeout(j.ctx, j.t.CallTimeout) | |
| 37 | + | resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs})) | |
| 38 | + | cancel() | |
| 39 | + | if err == nil { | |
| 40 | + | return resolveResponse(resp.Msg, len(req.Uses)) | |
| 41 | + | } | |
| 42 | + | if j.ctx.Err() != nil { | |
| 43 | + | return &execpb.ResolveResponse{Error: "the job was cancelled while its actions were resolved"} | |
| 44 | + | } | |
| 45 | + | switch classify(err) { | |
| 46 | + | case kindEnded, kindAbandoned: | |
| 47 | + | j.abandon(err) | |
| 48 | + | return &execpb.ResolveResponse{Error: "the server ended the job"} | |
| 49 | + | case kindPermanent: | |
| 50 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))} | |
| 51 | + | } | |
| 52 | + | j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed") | |
| 53 | + | if attempt >= 3 || !sleep(j.ctx, b.next()) { | |
| 54 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))} | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse { | |
| 60 | + | if len(m.Actions) != want { | |
| 61 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)} | |
| 62 | + | } | |
| 63 | + | out := &execpb.ResolveResponse{} | |
| 64 | + | for _, a := range m.Actions { | |
| 65 | + | out.Actions = append(out.Actions, &execpb.ResolvedAction{ | |
| 66 | + | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha, | |
| 67 | + | TarballUrl: a.TarballUrl, Token: a.Token, Source: a.Source.String(), Error: a.Error, | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | return out | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // SetGitHubToken returns the contexts JSON of a job with github.token set to | |
| 74 | + | // token (the job repository token, Job.github_token). The key is added first, | |
| 75 | + | // like the runner does (ExecutionContext.cs:1023: token, then what the server | |
| 76 | + | // gave); an empty token leaves the contexts as they are. | |
| 77 | + | func SetGitHubToken(contexts, token string) (string, error) { | |
| 78 | + | if token == "" { | |
| 79 | + | return contexts, nil | |
| 80 | + | } | |
| 81 | + | top, err := parseObject([]byte(contexts)) | |
| 82 | + | if err != nil { | |
| 83 | + | return "", fmt.Errorf("job contexts: %w", err) | |
| 84 | + | } | |
| 85 | + | gi := -1 | |
| 86 | + | for i, m := range top { | |
| 87 | + | if m.key == "github" { | |
| 88 | + | gi = i | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | var gh []jsonMember | |
| 92 | + | if gi >= 0 { | |
| 93 | + | if gh, err = parseObject(top[gi].val); err != nil { | |
| 94 | + | return "", fmt.Errorf("job contexts: github: %w", err) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | tv, err := json.Marshal(token) | |
| 98 | + | if err != nil { | |
| 99 | + | return "", err | |
| 100 | + | } | |
| 101 | + | set := false | |
| 102 | + | for i, m := range gh { | |
| 103 | + | if m.key == "token" { | |
| 104 | + | gh[i].val, set = tv, true | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | if !set { | |
| 108 | + | gh = append([]jsonMember{{"token", tv}}, gh...) | |
| 109 | + | } | |
| 110 | + | ghv, err := encodeObject(gh) | |
| 111 | + | if err != nil { | |
| 112 | + | return "", err | |
| 113 | + | } | |
| 114 | + | if gi >= 0 { | |
| 115 | + | top[gi].val = ghv | |
| 116 | + | } else { | |
| 117 | + | top = append(top, jsonMember{"github", ghv}) | |
| 118 | + | } | |
| 119 | + | out, err := encodeObject(top) | |
| 120 | + | return string(out), err | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | ||||
| 12 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // maxResolveRefs is the limit of ResolveActionsRequest.refs. | |||
| 18 | + | const maxResolveRefs = 100 | |||
| 19 | + | ||||
| 20 | + | // resolveActions answers a ResolveRequest of the executor with | |||
| 21 | + | // RunnerService.ResolveActions. The job token stays here: the executor only | |||
| 22 | + | // gets what the server returns (the per-archive tokens included, which it | |||
| 23 | + | // masks). A call that fails without a verdict is retried like the other job | |||
| 24 | + | // calls; a failure of the call itself is reported to the executor, which fails | |||
| 25 | + | // "Set up job" (ActionManager: FailedToResolveActionDownloadInfoException). | |||
| 26 | + | func (j *jobRun) resolveActions(req *execpb.ResolveRequest) *execpb.ResolveResponse { | |||
| 27 | + | if len(req.Uses) > maxResolveRefs { | |||
| 28 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)} | |||
| 29 | + | } | |||
| 30 | + | refs := make([]*ermite.ActionRef, len(req.Uses)) | |||
| 31 | + | for i, u := range req.Uses { | |||
| 32 | + | refs[i] = &ermite.ActionRef{Uses: u} | |||
| 33 | + | } | |||
| 34 | + | b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax} | |||
| 35 | + | for attempt := 1; ; attempt++ { | |||
| 36 | + | cctx, cancel := context.WithTimeout(j.ctx, j.t.CallTimeout) | |||
| 37 | + | resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs})) | |||
| 38 | + | cancel() | |||
| 39 | + | if err == nil { | |||
| 40 | + | return resolveResponse(resp.Msg, len(req.Uses)) | |||
| 41 | + | } | |||
| 42 | + | if j.ctx.Err() != nil { | |||
| 43 | + | return &execpb.ResolveResponse{Error: "the job was cancelled while its actions were resolved"} | |||
| 44 | + | } | |||
| 45 | + | switch classify(err) { | |||
| 46 | + | case kindEnded, kindAbandoned: | |||
| 47 | + | j.abandon(err) | |||
| 48 | + | return &execpb.ResolveResponse{Error: "the server ended the job"} | |||
| 49 | + | case kindPermanent: | |||
| 50 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))} | |||
| 51 | + | } | |||
| 52 | + | j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed") | |||
| 53 | + | if attempt >= 3 || !sleep(j.ctx, b.next()) { | |||
| 54 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))} | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse { | |||
| 60 | + | if len(m.Actions) != want { | |||
| 61 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)} | |||
| 62 | + | } | |||
| 63 | + | out := &execpb.ResolveResponse{} | |||
| 64 | + | for _, a := range m.Actions { | |||
| 65 | + | out.Actions = append(out.Actions, &execpb.ResolvedAction{ | |||
| 66 | + | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha, | |||
| 67 | + | TarballUrl: a.TarballUrl, Token: a.Token, Source: a.Source.String(), Error: a.Error, | |||
| 68 | + | }) | |||
| 69 | + | } | |||
| 70 | + | return out | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // SetGitHubToken returns the contexts JSON of a job with github.token set to | |||
| 74 | + | // token (the job repository token, Job.github_token). The key is added first, | |||
| 75 | + | // like the runner does (ExecutionContext.cs:1023: token, then what the server | |||
| 76 | + | // gave); an empty token leaves the contexts as they are. | |||
| 77 | + | func SetGitHubToken(contexts, token string) (string, error) { | |||
| 78 | + | if token == "" { | |||
| 79 | + | return contexts, nil | |||
| 80 | + | } | |||
| 81 | + | top, err := parseObject([]byte(contexts)) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return "", fmt.Errorf("job contexts: %w", err) | |||
| 84 | + | } | |||
| 85 | + | gi := -1 | |||
| 86 | + | for i, m := range top { | |||
| 87 | + | if m.key == "github" { | |||
| 88 | + | gi = i | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | var gh []jsonMember | |||
| 92 | + | if gi >= 0 { | |||
| 93 | + | if gh, err = parseObject(top[gi].val); err != nil { | |||
| 94 | + | return "", fmt.Errorf("job contexts: github: %w", err) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | tv, err := json.Marshal(token) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return "", err | |||
| 100 | + | } | |||
| 101 | + | set := false | |||
| 102 | + | for i, m := range gh { | |||
| 103 | + | if m.key == "token" { | |||
| 104 | + | gh[i].val, set = tv, true | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | if !set { | |||
| 108 | + | gh = append([]jsonMember{{"token", tv}}, gh...) | |||
| 109 | + | } | |||
| 110 | + | ghv, err := encodeObject(gh) | |||
| 111 | + | if err != nil { | |||
| 112 | + | return "", err | |||
| 113 | + | } | |||
| 114 | + | if gi >= 0 { | |||
| 115 | + | top[gi].val = ghv | |||
| 116 | + | } else { | |||
| 117 | + | top = append(top, jsonMember{"github", ghv}) | |||
| 118 | + | } | |||
| 119 | + | out, err := encodeObject(top) | |||
| 120 | + | return string(out), err | |||
| 121 | + | } | |||
A internal/host/resolve_test.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package host | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | ||
| 15 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type resolveStub struct { | |
| 21 | + | stubClient | |
| 22 | + | got *ermite.ResolveActionsRequest | |
| 23 | + | resp *ermite.ResolveActionsResponse | |
| 24 | + | err error | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) { | |
| 28 | + | c.got = r.Msg | |
| 29 | + | if c.err != nil { | |
| 30 | + | return nil, c.err | |
| 31 | + | } | |
| 32 | + | return connect.NewResponse(c.resp), nil | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func resolveJob(c jobClient) *jobRun { | |
| 36 | + | return &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{}, | |
| 37 | + | t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestResolveActions(t *testing.T) { | |
| 41 | + | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{ | |
| 42 | + | {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://x/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE}, | |
| 43 | + | {Ref: "a/c@v1", Error: "boom"}, | |
| 44 | + | }}} | |
| 45 | + | resp := resolveJob(c).resolveActions(&execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}}) | |
| 46 | + | if resp.Error != "" || len(resp.Actions) != 2 { | |
| 47 | + | t.Fatalf("%+v", resp) | |
| 48 | + | } | |
| 49 | + | if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" { | |
| 50 | + | t.Errorf("request %+v", c.got) | |
| 51 | + | } | |
| 52 | + | if a := resp.Actions[0]; a.Token != "tok" || a.TarballUrl != "https://x/t" || a.NameWithOwner != "a/b" { | |
| 53 | + | t.Errorf("%+v", a) | |
| 54 | + | } | |
| 55 | + | if resp.Actions[1].Error != "boom" { | |
| 56 | + | t.Errorf("%+v", resp.Actions[1]) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestResolveActionsFailures(t *testing.T) { | |
| 61 | + | c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)} | |
| 62 | + | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") { | |
| 63 | + | t.Errorf("%+v", r) | |
| 64 | + | } | |
| 65 | + | c = &resolveStub{resp: &ermite.ResolveActionsResponse{}} | |
| 66 | + | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" { | |
| 67 | + | t.Errorf("a short answer must fail: %+v", r) | |
| 68 | + | } | |
| 69 | + | many := make([]string, maxResolveRefs+1) | |
| 70 | + | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: many}); r.Error == "" { | |
| 71 | + | t.Error("more than 100 refs must fail") | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestSetGitHubToken(t *testing.T) { | |
| 76 | + | cases := map[string]string{ | |
| 77 | + | `{"github":{"job":"j","token":""},"needs":{}}`: `{"github":{"job":"j","token":"T"},"needs":{}}`, | |
| 78 | + | `{"github":{"job":"j"}}`: `{"github":{"token":"T","job":"j"}}`, | |
| 79 | + | `{"needs":{}}`: `{"needs":{},"github":{"token":"T"}}`, | |
| 80 | + | } | |
| 81 | + | for in, want := range cases { | |
| 82 | + | got, err := SetGitHubToken(in, "T") | |
| 83 | + | if err != nil || got != want { | |
| 84 | + | t.Errorf("%s: %s %v, want %s", in, got, err, want) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | if got, _ := SetGitHubToken(`{"a":1}`, ""); got != `{"a":1}` { | |
| 88 | + | t.Errorf("empty token changed the contexts: %s", got) | |
| 89 | + | } | |
| 90 | + | if _, err := SetGitHubToken(`[]`, "T"); err == nil { | |
| 91 | + | t.Error("not an object") | |
| 92 | + | } | |
| 93 | + | var m map[string]any | |
| 94 | + | if got, _ := SetGitHubToken(`{}`, `a"b\`); json.Unmarshal([]byte(got), &m) != nil { | |
| 95 | + | t.Errorf("invalid JSON: %s", got) | |
| 96 | + | } | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "connectrpc.com/connect" | |||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | ||||
| 15 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | type resolveStub struct { | |||
| 21 | + | stubClient | |||
| 22 | + | got *ermite.ResolveActionsRequest | |||
| 23 | + | resp *ermite.ResolveActionsResponse | |||
| 24 | + | err error | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) { | |||
| 28 | + | c.got = r.Msg | |||
| 29 | + | if c.err != nil { | |||
| 30 | + | return nil, c.err | |||
| 31 | + | } | |||
| 32 | + | return connect.NewResponse(c.resp), nil | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func resolveJob(c jobClient) *jobRun { | |||
| 36 | + | return &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{}, | |||
| 37 | + | t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestResolveActions(t *testing.T) { | |||
| 41 | + | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{ | |||
| 42 | + | {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://x/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE}, | |||
| 43 | + | {Ref: "a/c@v1", Error: "boom"}, | |||
| 44 | + | }}} | |||
| 45 | + | resp := resolveJob(c).resolveActions(&execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}}) | |||
| 46 | + | if resp.Error != "" || len(resp.Actions) != 2 { | |||
| 47 | + | t.Fatalf("%+v", resp) | |||
| 48 | + | } | |||
| 49 | + | if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" { | |||
| 50 | + | t.Errorf("request %+v", c.got) | |||
| 51 | + | } | |||
| 52 | + | if a := resp.Actions[0]; a.Token != "tok" || a.TarballUrl != "https://x/t" || a.NameWithOwner != "a/b" { | |||
| 53 | + | t.Errorf("%+v", a) | |||
| 54 | + | } | |||
| 55 | + | if resp.Actions[1].Error != "boom" { | |||
| 56 | + | t.Errorf("%+v", resp.Actions[1]) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestResolveActionsFailures(t *testing.T) { | |||
| 61 | + | c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)} | |||
| 62 | + | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") { | |||
| 63 | + | t.Errorf("%+v", r) | |||
| 64 | + | } | |||
| 65 | + | c = &resolveStub{resp: &ermite.ResolveActionsResponse{}} | |||
| 66 | + | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" { | |||
| 67 | + | t.Errorf("a short answer must fail: %+v", r) | |||
| 68 | + | } | |||
| 69 | + | many := make([]string, maxResolveRefs+1) | |||
| 70 | + | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: many}); r.Error == "" { | |||
| 71 | + | t.Error("more than 100 refs must fail") | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestSetGitHubToken(t *testing.T) { | |||
| 76 | + | cases := map[string]string{ | |||
| 77 | + | `{"github":{"job":"j","token":""},"needs":{}}`: `{"github":{"job":"j","token":"T"},"needs":{}}`, | |||
| 78 | + | `{"github":{"job":"j"}}`: `{"github":{"token":"T","job":"j"}}`, | |||
| 79 | + | `{"needs":{}}`: `{"needs":{},"github":{"token":"T"}}`, | |||
| 80 | + | } | |||
| 81 | + | for in, want := range cases { | |||
| 82 | + | got, err := SetGitHubToken(in, "T") | |||
| 83 | + | if err != nil || got != want { | |||
| 84 | + | t.Errorf("%s: %s %v, want %s", in, got, err, want) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | if got, _ := SetGitHubToken(`{"a":1}`, ""); got != `{"a":1}` { | |||
| 88 | + | t.Errorf("empty token changed the contexts: %s", got) | |||
| 89 | + | } | |||
| 90 | + | if _, err := SetGitHubToken(`[]`, "T"); err == nil { | |||
| 91 | + | t.Error("not an object") | |||
| 92 | + | } | |||
| 93 | + | var m map[string]any | |||
| 94 | + | if got, _ := SetGitHubToken(`{}`, `a"b\`); json.Unmarshal([]byte(got), &m) != nil { | |||
| 95 | + | t.Errorf("invalid JSON: %s", got) | |||
| 96 | + | } | |||
| 97 | + | } | |||
M internal/host/shutdown_test.go +4 -0
| @@ -46,6 +46,10 @@ func (c *stubClient) WatchJob(context.Context, *connect.Request[ermite.WatchJobR | |||
| 46 | 46 | return nil, connect.NewError(connect.CodeUnimplemented, nil) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func (c *stubClient) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) { | |
| 50 | + | return nil, connect.NewError(connect.CodeUnimplemented, nil) | |
| 51 | + | } | |
| 52 | + | ||
| 49 | 53 | func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) { | |
| 50 | 54 | c.mu.Lock() | |
| 51 | 55 | c.deadline, _ = ctx.Deadline() | |
| @@ -46,6 +46,10 @@ func (c *stubClient) WatchJob(context.Context, *connect.Request[ermite.WatchJobR | |||||
| 46 | return nil, connect.NewError(connect.CodeUnimplemented, nil) | 46 | return nil, connect.NewError(connect.CodeUnimplemented, nil) | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | + | func (c *stubClient) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) { | |||
| 50 | + | return nil, connect.NewError(connect.CodeUnimplemented, nil) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 49 | func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) { | 53 | func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) { | ||
| 50 | c.mu.Lock() | 54 | c.mu.Lock() | ||
| 51 | c.deadline, _ = ctx.Deadline() | 55 | c.deadline, _ = ctx.Deadline() | ||