1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "path"
11 "strings"
12
13 "bnema.dev/ermite-actions/workflow"
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// jobTokenActor is the actor of a run dispatched with a job token, GitHub's name
19// for the account behind a GITHUB_TOKEN.
20const jobTokenActor = "github-actions[bot]"
21
22// DispatchWorkflow starts a run of a workflow file by hand (workflow_dispatch,
23// CI-3 D5), like `gh workflow run`.
24//
25// Who may: a principal with ci:write on the repository, or the holder of a job
26// token whose permissions include "actions: write" (GitHub's one exception to
27// "events made with GITHUB_TOKEN start no run"; a token of a run that is not
28// trusted never has it, TokenPermissions lowers it, so a job token that gets
29// here belongs to a trusted run). A token limited to branch prefixes may only
30// dispatch on a branch under them, and its run is not trusted (D1). Whoever
31// calls, the run is trusted only when the ref it runs on was moved to its commit
32// by a trusted principal (ci_tips.go).
33//
34// The workflow is read at ref (the default branch when empty) and must declare
35// workflow_dispatch; the inputs are checked against its declaration.
36func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) {
37 repo, actor, pusher, trusted, err := c.dispatchCaller(ctx, p, name)
38 if err != nil {
39 return domain.CIRun{}, err
40 }
41 wfPath, err := dispatchPath(params.WorkflowPath)
42 if err != nil {
43 return domain.CIRun{}, err
44 }
45 pin, err := c.dispatchRef(ctx, name, params.Ref)
46 if err != nil {
47 return domain.CIRun{}, err
48 }
49 ref, commit := pin.ref, pin.commit
50 if c.cfg.DispatchResolved != nil {
51 c.cfg.DispatchResolved()
52 }
53 if !p.AllowsRef(ref) {
54 return domain.CIRun{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, ref)
55 }
56 content, err := c.readWorkflowAt(ctx, name, commit, wfPath)
57 if err != nil {
58 return domain.CIRun{}, err
59 }
60 wf, perrs := workflow.Parse(wfPath, content)
61 if len(perrs) > 0 {
62 lines := make([]string, 0, len(perrs))
63 for _, e := range perrs {
64 lines = append(lines, e.Error())
65 }
66 return domain.CIRun{}, fmt.Errorf("%w: %s is not a valid workflow:\n%s", domain.ErrInvalid, wfPath, strings.Join(lines, "\n"))
67 }
68 ev := wf.On.Event(eventDispatch)
69 if ev == nil {
70 return domain.CIRun{}, fmt.Errorf("%w: %s does not have a %s trigger", domain.ErrPreconditionFailed, wfPath, eventDispatch)
71 }
72 values, err := resolveDispatchInputs(ev.Inputs, params.Inputs)
73 if err != nil {
74 return domain.CIRun{}, err
75 }
76
77 f := pushFacts{
78 update: domain.RefUpdate{Ref: ref, Old: domain.ZeroOID, New: commit.String()}, commit: commit,
79 custom: &customEvent{name: eventDispatch, title: workflowName(wf, wfPath), inputs: values},
80 }
81 if def, _, err := c.cfg.Repos.DefaultBranch(ctx, name); err == nil {
82 f.defaultBranch = def
83 }
84 // The run executes the code of the ref, which the caller did not necessarily
85 // write: it is trusted only if the caller is AND whoever moved the ref to this
86 // commit was (domain.CIRefTip), as a re-run of an agent's run by the owner
87 // does not unlock secrets. A ref with no record is not trusted. This holds for
88 // every caller, the owner and a job token alike.
89 trusted = trusted && c.refTrusted(ctx, name, pin)
90 t := ciTrigger{repo: name, actor: actor, pusher: pusher, trusted: trusted, at: c.now()}
91 if problem := c.matrixProblem(ctx, repo, t, f, wfPath, wf); problem.text != "" {
92 return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text)
93 }
94 run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf)
95 if err != nil {
96 return domain.CIRun{}, err
97 }
98 if run.ID == 0 {
99 return domain.CIRun{}, domain.ErrNotFound
100 }
101 ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", run.ID).Str("workflow", wfPath).Str("ref", ref).
102 Str("principal", p.Label()).Bool("trusted", trusted).Msg("ci workflow dispatched")
103 return run, nil
104}
105
106// dispatchCaller applies the access rule of a dispatch and returns who the run
107// is for.
108func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain.RepoName) (repo domain.Repo, actor string, pusher domain.CIPusher, trusted bool, err error) {
109 if p.ViaJobToken() {
110 repo, err = lookupRepo(ctx, c.cfg.Meta, name)
111 if err != nil {
112 return repo, "", pusher, false, err
113 }
114 // The permission is judged on its own: a job of this repository with
115 // actions: write may dispatch even when its contents permission is none
116 // (a private repository is then unreadable to it). JobPermits is true only
117 // for the job's own token on its own repository, so any other caller of
118 // a repository it cannot read still gets not found.
119 switch {
120 case p.JobPermits("actions", domain.PermissionWrite, name):
121 return repo, jobTokenActor, domain.PusherOf(p), true, nil
122 case !canRead(p, repo):
123 return repo, "", pusher, false, domain.ErrNotFound
124 }
125 return repo, "", pusher, false, fmt.Errorf("%w: a job token needs the permission actions: write to dispatch a workflow", domain.ErrForbidden)
126 }
127 repo, err = c.writableRepo(ctx, p, name)
128 if err != nil {
129 return repo, "", pusher, false, err
130 }
131 return repo, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p), nil
132}
133
134// dispatchPath checks the workflow file of a dispatch: a file of
135// .github/workflows, named by its path or by its file name.
136func dispatchPath(s string) (string, error) {
137 s = strings.TrimSpace(s)
138 if s == "" {
139 return "", fmt.Errorf("%w: the workflow file is required", domain.ErrInvalid)
140 }
141 if !strings.Contains(s, "/") {
142 s = workflowDir + "/" + s
143 }
144 ext := strings.ToLower(path.Ext(s))
145 if path.Dir(s) != workflowDir || (ext != ".yml" && ext != ".yaml") {
146 return "", fmt.Errorf("%w: %q is not a workflow file of %s", domain.ErrInvalid, s, workflowDir)
147 }
148 return s, nil
149}
150
151// pinnedRef is what a dispatch resolved its ref to, once: the full name, the
152// commit the run executes and the object the ref points to (the commit for a
153// branch and a lightweight tag, the tag object for an annotated tag). The trust
154// of the run is judged on exactly these, never on the ref as it is read later
155// (ci_tips.go).
156type pinnedRef struct {
157 ref string
158 commit, object domain.ObjectID
159}
160
161// dispatchRef resolves the ref of a dispatch: a branch or a tag, by short or
162// full name; the default branch when empty. A tag is resolved to its commit and
163// its own object together; a tag that moves between the two reads is refused,
164// so the commit and the object always belong to one state of the ref.
165func (c *CI) dispatchRef(ctx context.Context, name domain.RepoName, given string) (pinnedRef, error) {
166 given = strings.TrimSpace(given)
167 if given == "" {
168 def, exists, err := c.cfg.Repos.DefaultBranch(ctx, name)
169 if err != nil {
170 return pinnedRef{}, err
171 }
172 if !exists {
173 return pinnedRef{}, fmt.Errorf("%w: the default branch %q does not exist", domain.ErrNotFound, def)
174 }
175 given = "refs/heads/" + def
176 }
177 rev, err := domain.ParseRevision(given)
178 if err != nil {
179 return pinnedRef{}, err
180 }
181 res, err := c.cfg.Reader.ResolveRevision(ctx, name, rev)
182 if err != nil {
183 return pinnedRef{}, err
184 }
185 pin := pinnedRef{ref: res.Ref, commit: res.CommitID, object: res.CommitID}
186 switch {
187 case strings.HasPrefix(res.Ref, "refs/heads/"):
188 return pin, nil
189 case !strings.HasPrefix(res.Ref, "refs/tags/"):
190 return pinnedRef{}, fmt.Errorf("%w: %q is not a branch or a tag", domain.ErrInvalid, given)
191 }
192 refs, err := c.cfg.Reader.ListRefs(ctx, name)
193 if err != nil {
194 return pinnedRef{}, err
195 }
196 for _, r := range refs {
197 if r.Name != res.Ref {
198 continue
199 }
200 if r.CommitID != res.CommitID || r.ID.IsZero() {
201 break // moved between the two reads
202 }
203 pin.object = r.ID
204 return pin, nil
205 }
206 return pinnedRef{}, fmt.Errorf("%w: tag %q moved while it was resolved, try again", domain.ErrConflict, res.Ref)
207}
208
209// readWorkflowAt reads a workflow file at a commit, bounded; a missing file is
210// domain.ErrNotFound.
211func (c *CI) readWorkflowAt(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, wfPath string) ([]byte, error) {
212 tp, err := domain.ParseTreePath(wfPath)
213 if err != nil {
214 return nil, err
215 }
216 _, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(commit), tp)
217 if err != nil {
218 if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrNotFile) {
219 return nil, fmt.Errorf("%w: workflow %s", domain.ErrNotFound, wfPath)
220 }
221 return nil, fmt.Errorf("read %s: %w", wfPath, err)
222 }
223 content, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
224 _ = rc.Close()
225 if err != nil {
226 return nil, fmt.Errorf("read %s: %w", wfPath, err)
227 }
228 if len(content) > maxCIWorkflowBytes {
229 return nil, fmt.Errorf("%w: %s is larger than 1 MiB", domain.ErrInvalid, wfPath)
230 }
231 return content, nil
232}