feat(ci): reusable workflows (#33)
- commit
- c11af01c6987ffc7e4db6cb9e7eddc64b035da09
- author
- brice · · 2026-10-15 05:33 UTC
- committer
- bnema · · 2026-10-15 05:33 UTC
- parent
- dc42452
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
26 files · +3487 -172
M README.md +1 -1
| @@ -127,7 +127,7 @@ The Actions that run here call a small part of GitHub's REST API, served at `git | |||
| 127 | 127 | ||
| 128 | 128 | Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git. | |
| 129 | 129 | ||
| 130 | - | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. `concurrency:` is supported at the workflow level (evaluated when the run starts) and at the job level (evaluated when the job is planned, once per matrix leg): a group is per repository and compared without regard to case, `queue: single` (the default) lets one run or job go and keeps one waiting, a newer waiting entry cancels the older one (`Canceling since a higher priority waiting request for '<group>' exists`), and `cancel-in-progress` (a boolean or an expression) also cancels the running one. A job held back by a group shows `pending` in the API, the web pages and the CLI. A group that evaluates to nothing fails the job, and `queue: max` is refused with `not supported yet: queue: max`. A workflow that uses another feature ermite does not support yet (reusable workflows) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`, and the job summaries (the Markdown a step writes to `GITHUB_STEP_SUMMARY`) under `$ERMITE_DATA_DIR/ci/summaries`; both go with their run (retention, repository deletion). Annotations (`::notice`, `::warning`, `::error`) are kept in the metadata store. The run page shows them in an Annotations box and the job page at their step; the summaries are rendered under the run with the README renderer (raw HTML dropped, `javascript:` links dropped). `ermite run view` prints an ANNOTATIONS section, and `ermite run view --summary` prints the summaries as Markdown. | |
| 130 | + | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. `concurrency:` is supported at the workflow level (evaluated when the run starts) and at the job level (evaluated when the job is planned, once per matrix leg): a group is per repository and compared without regard to case, `queue: single` (the default) lets one run or job go and keeps one waiting, a newer waiting entry cancels the older one (`Canceling since a higher priority waiting request for '<group>' exists`), and `cancel-in-progress` (a boolean or an expression) also cancels the running one. A job held back by a group shows `pending` in the API, the web pages and the CLI. A group that evaluates to nothing fails the job, and `queue: max` is refused with `not supported yet: queue: max`. Reusable workflows (`jobs.<id>.uses`) run: `./.github/workflows/x.yml` (the caller's repository, at the commit of the run), `owner/repo/.github/workflows/x.yml@ref` of a repository of this server when `owner` is `ERMITE_OWNER` and the actor of the run can read it (else the call fails as for a repository that does not exist), and otherwise a public repository on github.com, whose ref is resolved like the ref of an action and whose file is downloaded at that commit (at most 1 MiB). ermite expands the call when the job is planned: the called workflow must declare `on: workflow_call`, `with:` is checked against its inputs (types, `required`, defaults), `secrets:` passes the listed secrets, renamed as written, or all of them with `secrets: inherit` (a secret that is not passed is not there), and its `outputs` are available to the later jobs of the caller as `needs.<job>.outputs`. The jobs of the called workflow are named `<calling job> / <job>` and keyed `<calling job id>/<job id>`, wait for the `needs` of the calling job, and are repeated for each leg of its `strategy`; `permissions` can only be lowered by the called workflow. A call may nest four levels deep. `concurrency:` on the calling job and the workflow-level `concurrency:` of the called workflow (evaluated with the caller's `github` and the call's `inputs`) apply to the whole call, which holds the group until it ends, with `cancel-in-progress` as for any job. A workflow that uses another feature ermite does not support yet fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`, and the job summaries (the Markdown a step writes to `GITHUB_STEP_SUMMARY`) under `$ERMITE_DATA_DIR/ci/summaries`; both go with their run (retention, repository deletion). Annotations (`::notice`, `::warning`, `::error`) are kept in the metadata store. The run page shows them in an Annotations box and the job page at their step; the summaries are rendered under the run with the README renderer (raw HTML dropped, `javascript:` links dropped). `ermite run view` prints an ANNOTATIONS section, and `ermite run view --summary` prints the summaries as Markdown. | |
| 131 | 131 | ||
| 132 | 132 | ## Web sign-in | |
| 133 | 133 | ||
| @@ -127,7 +127,7 @@ The Actions that run here call a small part of GitHub's REST API, served at `git | |||||
| 127 | 127 | ||||
| 128 | Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git. | 128 | Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git. | ||
| 129 | 129 | ||||
| 130 | - | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. `concurrency:` is supported at the workflow level (evaluated when the run starts) and at the job level (evaluated when the job is planned, once per matrix leg): a group is per repository and compared without regard to case, `queue: single` (the default) lets one run or job go and keeps one waiting, a newer waiting entry cancels the older one (`Canceling since a higher priority waiting request for '<group>' exists`), and `cancel-in-progress` (a boolean or an expression) also cancels the running one. A job held back by a group shows `pending` in the API, the web pages and the CLI. A group that evaluates to nothing fails the job, and `queue: max` is refused with `not supported yet: queue: max`. A workflow that uses another feature ermite does not support yet (reusable workflows) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`, and the job summaries (the Markdown a step writes to `GITHUB_STEP_SUMMARY`) under `$ERMITE_DATA_DIR/ci/summaries`; both go with their run (retention, repository deletion). Annotations (`::notice`, `::warning`, `::error`) are kept in the metadata store. The run page shows them in an Annotations box and the job page at their step; the summaries are rendered under the run with the README renderer (raw HTML dropped, `javascript:` links dropped). `ermite run view` prints an ANNOTATIONS section, and `ermite run view --summary` prints the summaries as Markdown. | 130 | + | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. `concurrency:` is supported at the workflow level (evaluated when the run starts) and at the job level (evaluated when the job is planned, once per matrix leg): a group is per repository and compared without regard to case, `queue: single` (the default) lets one run or job go and keeps one waiting, a newer waiting entry cancels the older one (`Canceling since a higher priority waiting request for '<group>' exists`), and `cancel-in-progress` (a boolean or an expression) also cancels the running one. A job held back by a group shows `pending` in the API, the web pages and the CLI. A group that evaluates to nothing fails the job, and `queue: max` is refused with `not supported yet: queue: max`. Reusable workflows (`jobs.<id>.uses`) run: `./.github/workflows/x.yml` (the caller's repository, at the commit of the run), `owner/repo/.github/workflows/x.yml@ref` of a repository of this server when `owner` is `ERMITE_OWNER` and the actor of the run can read it (else the call fails as for a repository that does not exist), and otherwise a public repository on github.com, whose ref is resolved like the ref of an action and whose file is downloaded at that commit (at most 1 MiB). ermite expands the call when the job is planned: the called workflow must declare `on: workflow_call`, `with:` is checked against its inputs (types, `required`, defaults), `secrets:` passes the listed secrets, renamed as written, or all of them with `secrets: inherit` (a secret that is not passed is not there), and its `outputs` are available to the later jobs of the caller as `needs.<job>.outputs`. The jobs of the called workflow are named `<calling job> / <job>` and keyed `<calling job id>/<job id>`, wait for the `needs` of the calling job, and are repeated for each leg of its `strategy`; `permissions` can only be lowered by the called workflow. A call may nest four levels deep. `concurrency:` on the calling job and the workflow-level `concurrency:` of the called workflow (evaluated with the caller's `github` and the call's `inputs`) apply to the whole call, which holds the group until it ends, with `cancel-in-progress` as for any job. A workflow that uses another feature ermite does not support yet fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`, and the job summaries (the Markdown a step writes to `GITHUB_STEP_SUMMARY`) under `$ERMITE_DATA_DIR/ci/summaries`; both go with their run (retention, repository deletion). Annotations (`::notice`, `::warning`, `::error`) are kept in the metadata store. The run page shows them in an Annotations box and the job page at their step; the summaries are rendered under the run with the README renderer (raw HTML dropped, `javascript:` links dropped). `ermite run view` prints an ANNOTATIONS section, and `ermite run view --summary` prints the summaries as Markdown. |
| 131 | 131 | ||||
| 132 | ## Web sign-in | 132 | ## Web sign-in | ||
| 133 | 133 | ||||
M gen/ermite/storage/ci.pb.go +323 -90 generated file: collapsed
A internal/adapters/githubraw/githubraw.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package githubraw downloads a reusable workflow file from | |
| 4 | + | // raw.githubusercontent.com at a commit (ports.WorkflowFetcher). It sends no | |
| 5 | + | // credentials, bounds the time and the size of the download, and follows no | |
| 6 | + | // redirect to another host. | |
| 7 | + | package githubraw | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "context" | |
| 11 | + | "fmt" | |
| 12 | + | "io" | |
| 13 | + | "net/http" | |
| 14 | + | "net/url" | |
| 15 | + | "strings" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // DefaultBaseURL is where the files are. | |
| 23 | + | const DefaultBaseURL = "https://raw.githubusercontent.com" | |
| 24 | + | ||
| 25 | + | // DefaultTimeout bounds one download. | |
| 26 | + | const DefaultTimeout = 20 * time.Second | |
| 27 | + | ||
| 28 | + | // Config configures a Fetcher; tests replace BaseURL and Client. | |
| 29 | + | type Config struct { | |
| 30 | + | BaseURL string | |
| 31 | + | Timeout time.Duration | |
| 32 | + | Client *http.Client | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Fetcher implements ports.WorkflowFetcher over HTTPS. | |
| 36 | + | type Fetcher struct { | |
| 37 | + | base string | |
| 38 | + | client *http.Client | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | var _ ports.WorkflowFetcher = (*Fetcher)(nil) | |
| 42 | + | ||
| 43 | + | // New builds a Fetcher. | |
| 44 | + | func New(cfg Config) *Fetcher { | |
| 45 | + | if cfg.BaseURL == "" { | |
| 46 | + | cfg.BaseURL = DefaultBaseURL | |
| 47 | + | } | |
| 48 | + | if cfg.Timeout <= 0 { | |
| 49 | + | cfg.Timeout = DefaultTimeout | |
| 50 | + | } | |
| 51 | + | c := cfg.Client | |
| 52 | + | if c == nil { | |
| 53 | + | c = &http.Client{Transport: http.DefaultTransport} | |
| 54 | + | } | |
| 55 | + | cc := *c | |
| 56 | + | cc.Timeout = cfg.Timeout | |
| 57 | + | cc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |
| 58 | + | return &Fetcher{base: strings.TrimRight(cfg.BaseURL, "/"), client: &cc} | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // FetchWorkflow downloads path of owner/repo at sha. | |
| 62 | + | func (f *Fetcher) FetchWorkflow(ctx context.Context, owner, repo, sha, path string, maxBytes int64) ([]byte, error) { | |
| 63 | + | segs := []string{owner, repo, sha} | |
| 64 | + | segs = append(segs, strings.Split(path, "/")...) | |
| 65 | + | for _, s := range segs { | |
| 66 | + | if s == "" || s == "." || s == ".." { | |
| 67 | + | return nil, fmt.Errorf("%w: bad workflow location", domain.ErrInvalid) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | esc := make([]string, len(segs)) | |
| 71 | + | for i, s := range segs { | |
| 72 | + | esc[i] = url.PathEscape(s) | |
| 73 | + | } | |
| 74 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.base+"/"+strings.Join(esc, "/"), nil) | |
| 75 | + | if err != nil { | |
| 76 | + | return nil, err | |
| 77 | + | } | |
| 78 | + | res, err := f.client.Do(req) | |
| 79 | + | if err != nil { | |
| 80 | + | if ctx.Err() != nil { | |
| 81 | + | return nil, ctx.Err() | |
| 82 | + | } | |
| 83 | + | return nil, fmt.Errorf("%w: download the reusable workflow: %w", domain.ErrUnavailable, err) | |
| 84 | + | } | |
| 85 | + | defer func() { _ = res.Body.Close() }() | |
| 86 | + | switch res.StatusCode { | |
| 87 | + | case http.StatusOK: | |
| 88 | + | case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden: | |
| 89 | + | return nil, domain.ErrNotFound | |
| 90 | + | default: | |
| 91 | + | return nil, fmt.Errorf("%w: the download answered %d", domain.ErrUnavailable, res.StatusCode) | |
| 92 | + | } | |
| 93 | + | data, err := io.ReadAll(io.LimitReader(res.Body, maxBytes+1)) | |
| 94 | + | if err != nil { | |
| 95 | + | return nil, fmt.Errorf("%w: download the reusable workflow: %w", domain.ErrUnavailable, err) | |
| 96 | + | } | |
| 97 | + | if int64(len(data)) > maxBytes { | |
| 98 | + | return nil, fmt.Errorf("%w: the reusable workflow is larger than %d bytes", domain.ErrResourceExhausted, maxBytes) | |
| 99 | + | } | |
| 100 | + | return data, nil | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package githubraw downloads a reusable workflow file from | |||
| 4 | + | // raw.githubusercontent.com at a commit (ports.WorkflowFetcher). It sends no | |||
| 5 | + | // credentials, bounds the time and the size of the download, and follows no | |||
| 6 | + | // redirect to another host. | |||
| 7 | + | package githubraw | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "fmt" | |||
| 12 | + | "io" | |||
| 13 | + | "net/http" | |||
| 14 | + | "net/url" | |||
| 15 | + | "strings" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // DefaultBaseURL is where the files are. | |||
| 23 | + | const DefaultBaseURL = "https://raw.githubusercontent.com" | |||
| 24 | + | ||||
| 25 | + | // DefaultTimeout bounds one download. | |||
| 26 | + | const DefaultTimeout = 20 * time.Second | |||
| 27 | + | ||||
| 28 | + | // Config configures a Fetcher; tests replace BaseURL and Client. | |||
| 29 | + | type Config struct { | |||
| 30 | + | BaseURL string | |||
| 31 | + | Timeout time.Duration | |||
| 32 | + | Client *http.Client | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Fetcher implements ports.WorkflowFetcher over HTTPS. | |||
| 36 | + | type Fetcher struct { | |||
| 37 | + | base string | |||
| 38 | + | client *http.Client | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | var _ ports.WorkflowFetcher = (*Fetcher)(nil) | |||
| 42 | + | ||||
| 43 | + | // New builds a Fetcher. | |||
| 44 | + | func New(cfg Config) *Fetcher { | |||
| 45 | + | if cfg.BaseURL == "" { | |||
| 46 | + | cfg.BaseURL = DefaultBaseURL | |||
| 47 | + | } | |||
| 48 | + | if cfg.Timeout <= 0 { | |||
| 49 | + | cfg.Timeout = DefaultTimeout | |||
| 50 | + | } | |||
| 51 | + | c := cfg.Client | |||
| 52 | + | if c == nil { | |||
| 53 | + | c = &http.Client{Transport: http.DefaultTransport} | |||
| 54 | + | } | |||
| 55 | + | cc := *c | |||
| 56 | + | cc.Timeout = cfg.Timeout | |||
| 57 | + | cc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |||
| 58 | + | return &Fetcher{base: strings.TrimRight(cfg.BaseURL, "/"), client: &cc} | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // FetchWorkflow downloads path of owner/repo at sha. | |||
| 62 | + | func (f *Fetcher) FetchWorkflow(ctx context.Context, owner, repo, sha, path string, maxBytes int64) ([]byte, error) { | |||
| 63 | + | segs := []string{owner, repo, sha} | |||
| 64 | + | segs = append(segs, strings.Split(path, "/")...) | |||
| 65 | + | for _, s := range segs { | |||
| 66 | + | if s == "" || s == "." || s == ".." { | |||
| 67 | + | return nil, fmt.Errorf("%w: bad workflow location", domain.ErrInvalid) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | esc := make([]string, len(segs)) | |||
| 71 | + | for i, s := range segs { | |||
| 72 | + | esc[i] = url.PathEscape(s) | |||
| 73 | + | } | |||
| 74 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.base+"/"+strings.Join(esc, "/"), nil) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return nil, err | |||
| 77 | + | } | |||
| 78 | + | res, err := f.client.Do(req) | |||
| 79 | + | if err != nil { | |||
| 80 | + | if ctx.Err() != nil { | |||
| 81 | + | return nil, ctx.Err() | |||
| 82 | + | } | |||
| 83 | + | return nil, fmt.Errorf("%w: download the reusable workflow: %w", domain.ErrUnavailable, err) | |||
| 84 | + | } | |||
| 85 | + | defer func() { _ = res.Body.Close() }() | |||
| 86 | + | switch res.StatusCode { | |||
| 87 | + | case http.StatusOK: | |||
| 88 | + | case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden: | |||
| 89 | + | return nil, domain.ErrNotFound | |||
| 90 | + | default: | |||
| 91 | + | return nil, fmt.Errorf("%w: the download answered %d", domain.ErrUnavailable, res.StatusCode) | |||
| 92 | + | } | |||
| 93 | + | data, err := io.ReadAll(io.LimitReader(res.Body, maxBytes+1)) | |||
| 94 | + | if err != nil { | |||
| 95 | + | return nil, fmt.Errorf("%w: download the reusable workflow: %w", domain.ErrUnavailable, err) | |||
| 96 | + | } | |||
| 97 | + | if int64(len(data)) > maxBytes { | |||
| 98 | + | return nil, fmt.Errorf("%w: the reusable workflow is larger than %d bytes", domain.ErrResourceExhausted, maxBytes) | |||
| 99 | + | } | |||
| 100 | + | return data, nil | |||
| 101 | + | } | |||
A internal/adapters/githubraw/githubraw_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package githubraw_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/adapters/githubraw" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // The server is local: the test never reaches github.com. | |
| 20 | + | func TestFetchWorkflow(t *testing.T) { | |
| 21 | + | t.Parallel() | |
| 22 | + | var got string | |
| 23 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 24 | + | got = r.URL.Path | |
| 25 | + | switch { | |
| 26 | + | case strings.HasSuffix(r.URL.Path, "/ok.yml"): | |
| 27 | + | _, _ = w.Write([]byte("on: workflow_call\n")) | |
| 28 | + | case strings.HasSuffix(r.URL.Path, "/big.yml"): | |
| 29 | + | _, _ = w.Write([]byte(strings.Repeat("x", 100))) | |
| 30 | + | case strings.HasSuffix(r.URL.Path, "/moved.yml"): | |
| 31 | + | http.Redirect(w, r, "http://127.0.0.1:1/x", http.StatusFound) | |
| 32 | + | case strings.HasSuffix(r.URL.Path, "/boom.yml"): | |
| 33 | + | w.WriteHeader(http.StatusBadGateway) | |
| 34 | + | default: | |
| 35 | + | http.NotFound(w, r) | |
| 36 | + | } | |
| 37 | + | })) | |
| 38 | + | t.Cleanup(srv.Close) | |
| 39 | + | f := githubraw.New(githubraw.Config{BaseURL: srv.URL}) | |
| 40 | + | ctx := context.Background() | |
| 41 | + | ||
| 42 | + | data, err := f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/ok.yml", 1024) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | assert.Equal(t, "on: workflow_call\n", string(data)) | |
| 45 | + | assert.Equal(t, "/o/r/abc/.github/workflows/ok.yml", got) | |
| 46 | + | ||
| 47 | + | _, err = f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/none.yml", 1024) | |
| 48 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 49 | + | _, err = f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/big.yml", 10) | |
| 50 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 51 | + | _, err = f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/boom.yml", 10) | |
| 52 | + | assert.ErrorIs(t, err, domain.ErrUnavailable) | |
| 53 | + | _, err = f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/moved.yml", 10) | |
| 54 | + | assert.ErrorIs(t, err, domain.ErrUnavailable, "a redirect is not followed") | |
| 55 | + | _, err = f.FetchWorkflow(ctx, "o", "..", "abc", ".github/workflows/ok.yml", 10) | |
| 56 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package githubraw_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/adapters/githubraw" | |||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // The server is local: the test never reaches github.com. | |||
| 20 | + | func TestFetchWorkflow(t *testing.T) { | |||
| 21 | + | t.Parallel() | |||
| 22 | + | var got string | |||
| 23 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 24 | + | got = r.URL.Path | |||
| 25 | + | switch { | |||
| 26 | + | case strings.HasSuffix(r.URL.Path, "/ok.yml"): | |||
| 27 | + | _, _ = w.Write([]byte("on: workflow_call\n")) | |||
| 28 | + | case strings.HasSuffix(r.URL.Path, "/big.yml"): | |||
| 29 | + | _, _ = w.Write([]byte(strings.Repeat("x", 100))) | |||
| 30 | + | case strings.HasSuffix(r.URL.Path, "/moved.yml"): | |||
| 31 | + | http.Redirect(w, r, "http://127.0.0.1:1/x", http.StatusFound) | |||
| 32 | + | case strings.HasSuffix(r.URL.Path, "/boom.yml"): | |||
| 33 | + | w.WriteHeader(http.StatusBadGateway) | |||
| 34 | + | default: | |||
| 35 | + | http.NotFound(w, r) | |||
| 36 | + | } | |||
| 37 | + | })) | |||
| 38 | + | t.Cleanup(srv.Close) | |||
| 39 | + | f := githubraw.New(githubraw.Config{BaseURL: srv.URL}) | |||
| 40 | + | ctx := context.Background() | |||
| 41 | + | ||||
| 42 | + | data, err := f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/ok.yml", 1024) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | assert.Equal(t, "on: workflow_call\n", string(data)) | |||
| 45 | + | assert.Equal(t, "/o/r/abc/.github/workflows/ok.yml", got) | |||
| 46 | + | ||||
| 47 | + | _, err = f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/none.yml", 1024) | |||
| 48 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 49 | + | _, err = f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/big.yml", 10) | |||
| 50 | + | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |||
| 51 | + | _, err = f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/boom.yml", 10) | |||
| 52 | + | assert.ErrorIs(t, err, domain.ErrUnavailable) | |||
| 53 | + | _, err = f.FetchWorkflow(ctx, "o", "r", "abc", ".github/workflows/moved.yml", 10) | |||
| 54 | + | assert.ErrorIs(t, err, domain.ErrUnavailable, "a redirect is not followed") | |||
| 55 | + | _, err = f.FetchWorkflow(ctx, "o", "..", "abc", ".github/workflows/ok.yml", 10) | |||
| 56 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |||
| 57 | + | } | |||
M internal/adapters/kvstore/ci.go +24 -1
| @@ -337,7 +337,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||
| 337 | 337 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | |
| 338 | 338 | TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage, | |
| 339 | 339 | CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment, Permissions: j.Permissions, | |
| 340 | - | Concurrency: encodeConcurrency(j.Concurrency), | |
| 340 | + | Concurrency: encodeConcurrency(j.Concurrency), Scope: j.Scope, CallId: j.CallID, Call: encodeCall(j.Call), | |
| 341 | 341 | } | |
| 342 | 342 | if j.HasToken() { | |
| 343 | 343 | pb.TokenHash = j.TokenHash[:] | |
| @@ -368,6 +368,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||
| 368 | 368 | AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()), | |
| 369 | 369 | CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()), | |
| 370 | 370 | Environment: pb.GetEnvironment(), Permissions: pb.GetPermissions(), Concurrency: decodeConcurrency(pb.GetConcurrency()), Version: item.Version, | |
| 371 | + | Scope: pb.GetScope(), CallID: pb.GetCallId(), Call: decodeCall(pb.GetCall()), | |
| 371 | 372 | } | |
| 372 | 373 | j.CancelReason = decodeCancelReason(pb.GetCancelReason()) | |
| 373 | 374 | copy(j.TokenHash[:], pb.GetTokenHash()) | |
| @@ -1062,3 +1063,25 @@ func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, erro | |||
| 1062 | 1063 | } | |
| 1063 | 1064 | return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, domain.ErrConflict) | |
| 1064 | 1065 | } | |
| 1066 | + | ||
| 1067 | + | func encodeCall(c *domain.CICall) *storagepb.CICall { | |
| 1068 | + | if c == nil { | |
| 1069 | + | return nil | |
| 1070 | + | } | |
| 1071 | + | return &storagepb.CICall{ | |
| 1072 | + | Uses: c.Uses, Path: c.Path, Repository: c.Repository, Ref: c.Ref, Sha: c.Sha, Yaml: c.YAML, InputsJson: c.InputsJSON, | |
| 1073 | + | Secrets: c.Secrets, Inherit: c.Inherit, Depth: int32(c.Depth), Expanded: c.Expanded, Concurrency: encodeConcurrency(c.Concurrency), Admitted: c.Admitted, SecretCtx: c.SecretCtx, //nolint:gosec // at most 4 | |
| 1074 | + | } | |
| 1075 | + | } | |
| 1076 | + | ||
| 1077 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored call to the domain call field by field: no rule is decided here | |
| 1078 | + | func decodeCall(pb *storagepb.CICall) *domain.CICall { | |
| 1079 | + | if pb == nil { | |
| 1080 | + | return nil | |
| 1081 | + | } | |
| 1082 | + | return &domain.CICall{ | |
| 1083 | + | Uses: pb.GetUses(), Path: pb.GetPath(), Repository: pb.GetRepository(), Ref: pb.GetRef(), Sha: pb.GetSha(), YAML: pb.GetYaml(), | |
| 1084 | + | InputsJSON: pb.GetInputsJson(), Secrets: pb.GetSecrets(), Inherit: pb.GetInherit(), Depth: int(pb.GetDepth()), Expanded: pb.GetExpanded(), Concurrency: decodeConcurrency(pb.GetConcurrency()), | |
| 1085 | + | Admitted: pb.GetAdmitted(), SecretCtx: pb.GetSecretCtx(), | |
| 1086 | + | } | |
| 1087 | + | } | |
| @@ -337,7 +337,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||||
| 337 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | 337 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | ||
| 338 | TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage, | 338 | TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage, | ||
| 339 | CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment, Permissions: j.Permissions, | 339 | CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment, Permissions: j.Permissions, | ||
| 340 | - | Concurrency: encodeConcurrency(j.Concurrency), | 340 | + | Concurrency: encodeConcurrency(j.Concurrency), Scope: j.Scope, CallId: j.CallID, Call: encodeCall(j.Call), |
| 341 | } | 341 | } | ||
| 342 | if j.HasToken() { | 342 | if j.HasToken() { | ||
| 343 | pb.TokenHash = j.TokenHash[:] | 343 | pb.TokenHash = j.TokenHash[:] | ||
| @@ -368,6 +368,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||||
| 368 | AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()), | 368 | AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()), | ||
| 369 | CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()), | 369 | CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()), | ||
| 370 | Environment: pb.GetEnvironment(), Permissions: pb.GetPermissions(), Concurrency: decodeConcurrency(pb.GetConcurrency()), Version: item.Version, | 370 | Environment: pb.GetEnvironment(), Permissions: pb.GetPermissions(), Concurrency: decodeConcurrency(pb.GetConcurrency()), Version: item.Version, | ||
| 371 | + | Scope: pb.GetScope(), CallID: pb.GetCallId(), Call: decodeCall(pb.GetCall()), | |||
| 371 | } | 372 | } | ||
| 372 | j.CancelReason = decodeCancelReason(pb.GetCancelReason()) | 373 | j.CancelReason = decodeCancelReason(pb.GetCancelReason()) | ||
| 373 | copy(j.TokenHash[:], pb.GetTokenHash()) | 374 | copy(j.TokenHash[:], pb.GetTokenHash()) | ||
| @@ -1062,3 +1063,25 @@ func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, erro | |||||
| 1062 | } | 1063 | } | ||
| 1063 | return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, domain.ErrConflict) | 1064 | return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, domain.ErrConflict) | ||
| 1064 | } | 1065 | } | ||
| 1066 | + | ||||
| 1067 | + | func encodeCall(c *domain.CICall) *storagepb.CICall { | |||
| 1068 | + | if c == nil { | |||
| 1069 | + | return nil | |||
| 1070 | + | } | |||
| 1071 | + | return &storagepb.CICall{ | |||
| 1072 | + | Uses: c.Uses, Path: c.Path, Repository: c.Repository, Ref: c.Ref, Sha: c.Sha, Yaml: c.YAML, InputsJson: c.InputsJSON, | |||
| 1073 | + | Secrets: c.Secrets, Inherit: c.Inherit, Depth: int32(c.Depth), Expanded: c.Expanded, Concurrency: encodeConcurrency(c.Concurrency), Admitted: c.Admitted, SecretCtx: c.SecretCtx, //nolint:gosec // at most 4 | |||
| 1074 | + | } | |||
| 1075 | + | } | |||
| 1076 | + | ||||
| 1077 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored call to the domain call field by field: no rule is decided here | |||
| 1078 | + | func decodeCall(pb *storagepb.CICall) *domain.CICall { | |||
| 1079 | + | if pb == nil { | |||
| 1080 | + | return nil | |||
| 1081 | + | } | |||
| 1082 | + | return &domain.CICall{ | |||
| 1083 | + | Uses: pb.GetUses(), Path: pb.GetPath(), Repository: pb.GetRepository(), Ref: pb.GetRef(), Sha: pb.GetSha(), YAML: pb.GetYaml(), | |||
| 1084 | + | InputsJSON: pb.GetInputsJson(), Secrets: pb.GetSecrets(), Inherit: pb.GetInherit(), Depth: int(pb.GetDepth()), Expanded: pb.GetExpanded(), Concurrency: decodeConcurrency(pb.GetConcurrency()), | |||
| 1085 | + | Admitted: pb.GetAdmitted(), SecretCtx: pb.GetSecretCtx(), | |||
| 1086 | + | } | |||
| 1087 | + | } | |||
M internal/adapters/kvstore/store.go +5 -2
| @@ -20,8 +20,11 @@ import ( | |||
| 20 | 20 | // SchemaVersion is the storage schema this build reads and writes. Version 2 | |
| 21 | 21 | // adds branch prefixes to tokens: a build that does not know them would treat | |
| 22 | 22 | // a branch-limited token as unrestricted, so it must refuse the data | |
| 23 | - | // (ErrSchemaTooNew) instead. | |
| 24 | - | const SchemaVersion = 2 | |
| 23 | + | // (ErrSchemaTooNew) instead. Version 3 adds the records of CI-3 that an older | |
| 24 | + | // build would misread: the call and scoped jobs of reusable workflows (it would | |
| 25 | + | // run the jobs of a called workflow as jobs of the caller's file, and plan the | |
| 26 | + | // call itself). | |
| 27 | + | const SchemaVersion = 3 | |
| 25 | 28 | ||
| 26 | 29 | const schemaKey = "meta/schema" | |
| 27 | 30 | ||
| @@ -20,8 +20,11 @@ import ( | |||||
| 20 | // SchemaVersion is the storage schema this build reads and writes. Version 2 | 20 | // SchemaVersion is the storage schema this build reads and writes. Version 2 | ||
| 21 | // adds branch prefixes to tokens: a build that does not know them would treat | 21 | // adds branch prefixes to tokens: a build that does not know them would treat | ||
| 22 | // a branch-limited token as unrestricted, so it must refuse the data | 22 | // a branch-limited token as unrestricted, so it must refuse the data | ||
| 23 | - | // (ErrSchemaTooNew) instead. | 23 | + | // (ErrSchemaTooNew) instead. Version 3 adds the records of CI-3 that an older |
| 24 | - | const SchemaVersion = 2 | 24 | + | // build would misread: the call and scoped jobs of reusable workflows (it would |
| 25 | + | // run the jobs of a called workflow as jobs of the caller's file, and plan the | |||
| 26 | + | // call itself). | |||
| 27 | + | const SchemaVersion = 3 | |||
| 25 | 28 | ||||
| 26 | const schemaKey = "meta/schema" | 29 | const schemaKey = "meta/schema" | ||
| 27 | 30 | ||||
M internal/adapters/kvstore/store_test.go +28 -4
| @@ -210,7 +210,7 @@ func TestSchema(t *testing.T) { | |||
| 210 | 210 | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 211 | 211 | }) | |
| 212 | 212 | ||
| 213 | - | t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) { | |
| 213 | + | t.Run("version 1 is upgraded to the current one, its data kept", func(t *testing.T) { | |
| 214 | 214 | dir := t.TempDir() | |
| 215 | 215 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1})) | |
| 216 | 216 | putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})) | |
| @@ -220,15 +220,39 @@ func TestSchema(t *testing.T) { | |||
| 220 | 220 | require.NoError(t, err) | |
| 221 | 221 | assert.Equal(t, "old", got.Name.String()) | |
| 222 | 222 | require.NoError(t, s.Close(ctx)) | |
| 223 | - | assert.Equal(t, uint32(2), readSchema(t, dir)) | |
| 223 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 224 | + | }) | |
| 225 | + | ||
| 226 | + | t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) { | |
| 227 | + | dir := t.TempDir() | |
| 228 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | |
| 229 | + | putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})) | |
| 230 | + | s, err := kvstore.Open(ctx, dir) | |
| 231 | + | require.NoError(t, err) | |
| 232 | + | _, err = s.Get(ctx, name(t, "old")) | |
| 233 | + | require.NoError(t, err) | |
| 234 | + | require.NoError(t, s.Close(ctx)) | |
| 235 | + | assert.Equal(t, uint32(3), readSchema(t, dir)) | |
| 236 | + | }) | |
| 237 | + | ||
| 238 | + | // Downgrade guard: once a build with reusable workflows has opened the data | |
| 239 | + | // (and may have stored calls), a build that knows schema 2 only must refuse | |
| 240 | + | // it; it does so for any version above its own (ErrSchemaTooNew), which is | |
| 241 | + | // the version 3 this build writes. | |
| 242 | + | t.Run("data written by this build is above schema 2", func(t *testing.T) { | |
| 243 | + | dir := t.TempDir() | |
| 244 | + | s, err := kvstore.Open(ctx, dir) | |
| 245 | + | require.NoError(t, err) | |
| 246 | + | require.NoError(t, s.Close(ctx)) | |
| 247 | + | assert.Greater(t, readSchema(t, dir), uint32(2), "a build that supports 2 refuses it") | |
| 224 | 248 | }) | |
| 225 | 249 | ||
| 226 | 250 | t.Run("newer stops startup and releases the lock", func(t *testing.T) { | |
| 227 | 251 | dir := t.TempDir() | |
| 228 | - | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3})) | |
| 252 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) | |
| 229 | 253 | _, err := kvstore.Open(ctx, dir) | |
| 230 | 254 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | |
| 231 | - | assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept") | |
| 255 | + | assert.Equal(t, uint32(kvstore.SchemaVersion+1), readSchema(t, dir), "the lock was released and the value kept") | |
| 232 | 256 | }) | |
| 233 | 257 | ||
| 234 | 258 | t.Run("garbage and zero are errors", func(t *testing.T) { | |
| @@ -210,7 +210,7 @@ func TestSchema(t *testing.T) { | |||||
| 210 | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | 210 | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | ||
| 211 | }) | 211 | }) | ||
| 212 | 212 | ||||
| 213 | - | t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) { | 213 | + | t.Run("version 1 is upgraded to the current one, its data kept", func(t *testing.T) { |
| 214 | dir := t.TempDir() | 214 | dir := t.TempDir() | ||
| 215 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1})) | 215 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1})) | ||
| 216 | putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})) | 216 | putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})) | ||
| @@ -220,15 +220,39 @@ func TestSchema(t *testing.T) { | |||||
| 220 | require.NoError(t, err) | 220 | require.NoError(t, err) | ||
| 221 | assert.Equal(t, "old", got.Name.String()) | 221 | assert.Equal(t, "old", got.Name.String()) | ||
| 222 | require.NoError(t, s.Close(ctx)) | 222 | require.NoError(t, s.Close(ctx)) | ||
| 223 | - | assert.Equal(t, uint32(2), readSchema(t, dir)) | 223 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) |
| 224 | + | }) | |||
| 225 | + | ||||
| 226 | + | t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) { | |||
| 227 | + | dir := t.TempDir() | |||
| 228 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | |||
| 229 | + | putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})) | |||
| 230 | + | s, err := kvstore.Open(ctx, dir) | |||
| 231 | + | require.NoError(t, err) | |||
| 232 | + | _, err = s.Get(ctx, name(t, "old")) | |||
| 233 | + | require.NoError(t, err) | |||
| 234 | + | require.NoError(t, s.Close(ctx)) | |||
| 235 | + | assert.Equal(t, uint32(3), readSchema(t, dir)) | |||
| 236 | + | }) | |||
| 237 | + | ||||
| 238 | + | // Downgrade guard: once a build with reusable workflows has opened the data | |||
| 239 | + | // (and may have stored calls), a build that knows schema 2 only must refuse | |||
| 240 | + | // it; it does so for any version above its own (ErrSchemaTooNew), which is | |||
| 241 | + | // the version 3 this build writes. | |||
| 242 | + | t.Run("data written by this build is above schema 2", func(t *testing.T) { | |||
| 243 | + | dir := t.TempDir() | |||
| 244 | + | s, err := kvstore.Open(ctx, dir) | |||
| 245 | + | require.NoError(t, err) | |||
| 246 | + | require.NoError(t, s.Close(ctx)) | |||
| 247 | + | assert.Greater(t, readSchema(t, dir), uint32(2), "a build that supports 2 refuses it") | |||
| 224 | }) | 248 | }) | ||
| 225 | 249 | ||||
| 226 | t.Run("newer stops startup and releases the lock", func(t *testing.T) { | 250 | t.Run("newer stops startup and releases the lock", func(t *testing.T) { | ||
| 227 | dir := t.TempDir() | 251 | dir := t.TempDir() | ||
| 228 | - | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3})) | 252 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) |
| 229 | _, err := kvstore.Open(ctx, dir) | 253 | _, err := kvstore.Open(ctx, dir) | ||
| 230 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | 254 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | ||
| 231 | - | assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept") | 255 | + | assert.Equal(t, uint32(kvstore.SchemaVersion+1), readSchema(t, dir), "the lock was released and the value kept") |
| 232 | }) | 256 | }) | ||
| 233 | 257 | ||||
| 234 | t.Run("garbage and zero are errors", func(t *testing.T) { | 258 | t.Run("garbage and zero are errors", func(t *testing.T) { | ||
M internal/app/ci_flow_test.go +3 -3
| @@ -316,8 +316,8 @@ jobs: | |||
| 316 | 316 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.runs()[0].GetConclusion()) | |
| 317 | 317 | } | |
| 318 | 318 | ||
| 319 | - | // A job with a feature of a later phase fails clearly, never silently. | |
| 320 | - | func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) { | |
| 319 | + | // A job that calls a reusable workflow which is not there fails clearly, never silently. | |
| 320 | + | func TestCICallOfAMissingWorkflowFailsTheJob(t *testing.T) { | |
| 321 | 321 | c := newCIHarness(t, nil) | |
| 322 | 322 | c.registerRunner("alpha", "ubuntu-latest") | |
| 323 | 323 | c.push("main", map[string]string{".github/workflows/u.yml": `on: push | |
| @@ -328,7 +328,7 @@ jobs: | |||
| 328 | 328 | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 329 | 329 | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | |
| 330 | 330 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | |
| 331 | - | assert.Equal(t, "not supported yet: reusable workflows (uses at the job level)", j.GetError()) | |
| 331 | + | assert.Contains(t, j.GetError(), "Unable to find reusable workflow '.github/workflows/callee.yml'") | |
| 332 | 332 | } | |
| 333 | 333 | ||
| 334 | 334 | // A job for a label no runner has is "waiting for a runner" and fails after the | |
| @@ -316,8 +316,8 @@ jobs: | |||||
| 316 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.runs()[0].GetConclusion()) | 316 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.runs()[0].GetConclusion()) | ||
| 317 | } | 317 | } | ||
| 318 | 318 | ||||
| 319 | - | // A job with a feature of a later phase fails clearly, never silently. | 319 | + | // A job that calls a reusable workflow which is not there fails clearly, never silently. |
| 320 | - | func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) { | 320 | + | func TestCICallOfAMissingWorkflowFailsTheJob(t *testing.T) { |
| 321 | c := newCIHarness(t, nil) | 321 | c := newCIHarness(t, nil) | ||
| 322 | c.registerRunner("alpha", "ubuntu-latest") | 322 | c.registerRunner("alpha", "ubuntu-latest") | ||
| 323 | c.push("main", map[string]string{".github/workflows/u.yml": `on: push | 323 | c.push("main", map[string]string{".github/workflows/u.yml": `on: push | ||
| @@ -328,7 +328,7 @@ jobs: | |||||
| 328 | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | 328 | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | ||
| 329 | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | 329 | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | ||
| 330 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | 330 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | ||
| 331 | - | assert.Equal(t, "not supported yet: reusable workflows (uses at the job level)", j.GetError()) | 331 | + | assert.Contains(t, j.GetError(), "Unable to find reusable workflow '.github/workflows/callee.yml'") |
| 332 | } | 332 | } | ||
| 333 | 333 | ||||
| 334 | // A job for a label no runner has is "waiting for a runner" and fails after the | 334 | // A job for a label no runner has is "waiting for a runner" and fails after the | ||
M internal/app/ci_parity_test.go +1 -23
| @@ -308,7 +308,7 @@ jobs: | |||
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | 310 | // CI-2: planning runs what CI-1 refused (uses, container, services, a step | |
| 311 | - | // timeout) and CI-3 environments, and still refuses job-level uses. | |
| 311 | + | // timeout). | |
| 312 | 312 | func TestCIPlanningAcceptsCI2Features(t *testing.T) { | |
| 313 | 313 | c := newCIHarness(t, nil) | |
| 314 | 314 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| @@ -327,28 +327,6 @@ jobs: | |||
| 327 | 327 | `}, "") | |
| 328 | 328 | job := runner.await() | |
| 329 | 329 | assert.Equal(t, "j", job.GetJobKey()) | |
| 330 | - | ||
| 331 | - | for _, tc := range []struct{ name, job, want string }{ | |
| 332 | - | {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"}, | |
| 333 | - | } { | |
| 334 | - | t.Run(tc.name, func(t *testing.T) { | |
| 335 | - | body := "on: push\njobs:\n r:\n " + tc.job + "\n" | |
| 336 | - | c.push("main", map[string]string{".github/workflows/" + tc.name + ".yml": body}, "") | |
| 337 | - | var got string | |
| 338 | - | c.waitFor(tc.name+" fails", func() bool { | |
| 339 | - | for _, r := range c.runs() { | |
| 340 | - | if r.GetWorkflowPath() == ".github/workflows/"+tc.name+".yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED { | |
| 341 | - | if jobs := c.getRun(r.GetId()).GetJobs(); len(jobs) == 1 { | |
| 342 | - | got = jobs[0].GetError() | |
| 343 | - | return true | |
| 344 | - | } | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | return false | |
| 348 | - | }) | |
| 349 | - | assert.Equal(t, tc.want, got) | |
| 350 | - | }) | |
| 351 | - | } | |
| 352 | 330 | } | |
| 353 | 331 | ||
| 354 | 332 | // A failed job with continue-on-error does not fail the run, keeps its own | |
| @@ -308,7 +308,7 @@ jobs: | |||||
| 308 | } | 308 | } | ||
| 309 | 309 | ||||
| 310 | // CI-2: planning runs what CI-1 refused (uses, container, services, a step | 310 | // CI-2: planning runs what CI-1 refused (uses, container, services, a step | ||
| 311 | - | // timeout) and CI-3 environments, and still refuses job-level uses. | 311 | + | // timeout). |
| 312 | func TestCIPlanningAcceptsCI2Features(t *testing.T) { | 312 | func TestCIPlanningAcceptsCI2Features(t *testing.T) { | ||
| 313 | c := newCIHarness(t, nil) | 313 | c := newCIHarness(t, nil) | ||
| 314 | runner := c.registerRunner("alpha", "ubuntu-latest") | 314 | runner := c.registerRunner("alpha", "ubuntu-latest") | ||
| @@ -327,28 +327,6 @@ jobs: | |||||
| 327 | `}, "") | 327 | `}, "") | ||
| 328 | job := runner.await() | 328 | job := runner.await() | ||
| 329 | assert.Equal(t, "j", job.GetJobKey()) | 329 | assert.Equal(t, "j", job.GetJobKey()) | ||
| 330 | - | ||||
| 331 | - | for _, tc := range []struct{ name, job, want string }{ | |||
| 332 | - | {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"}, | |||
| 333 | - | } { | |||
| 334 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 335 | - | body := "on: push\njobs:\n r:\n " + tc.job + "\n" | |||
| 336 | - | c.push("main", map[string]string{".github/workflows/" + tc.name + ".yml": body}, "") | |||
| 337 | - | var got string | |||
| 338 | - | c.waitFor(tc.name+" fails", func() bool { | |||
| 339 | - | for _, r := range c.runs() { | |||
| 340 | - | if r.GetWorkflowPath() == ".github/workflows/"+tc.name+".yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED { | |||
| 341 | - | if jobs := c.getRun(r.GetId()).GetJobs(); len(jobs) == 1 { | |||
| 342 | - | got = jobs[0].GetError() | |||
| 343 | - | return true | |||
| 344 | - | } | |||
| 345 | - | } | |||
| 346 | - | } | |||
| 347 | - | return false | |||
| 348 | - | }) | |||
| 349 | - | assert.Equal(t, tc.want, got) | |||
| 350 | - | }) | |||
| 351 | - | } | |||
| 352 | } | 330 | } | ||
| 353 | 331 | ||||
| 354 | // A failed job with continue-on-error does not fail the run, keeps its own | 332 | // A failed job with continue-on-error does not fail the run, keeps its own | ||
A internal/app/ci_reusable_review_test.go +265 -0
| @@ -0,0 +1,265 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestCIReusableSecretOutputCannotForgeAReference(t *testing.T) { | |
| 21 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 22 | + | c.setSecret(repoScope("proj"), "UNPASSED", "private-value") | |
| 23 | + | caller := `on: push | |
| 24 | + | jobs: | |
| 25 | + | prep: | |
| 26 | + | uses: ./.github/workflows/producer.yml | |
| 27 | + | call: | |
| 28 | + | needs: prep | |
| 29 | + | uses: ./.github/workflows/callee.yml | |
| 30 | + | secrets: | |
| 31 | + | TOKEN: ${{ needs.prep.outputs.payload }} | |
| 32 | + | ` | |
| 33 | + | producer := `on: | |
| 34 | + | workflow_call: | |
| 35 | + | outputs: | |
| 36 | + | payload: | |
| 37 | + | value: ${{ jobs.j.outputs.payload }} | |
| 38 | + | jobs: | |
| 39 | + | j: | |
| 40 | + | runs-on: ubuntu-latest | |
| 41 | + | outputs: | |
| 42 | + | payload: ${{ steps.s.outputs.payload }} | |
| 43 | + | steps: | |
| 44 | + | - id: s | |
| 45 | + | run: echo | |
| 46 | + | ` | |
| 47 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/producer.yml": producer, ".github/workflows/callee.yml": secretsCallee}, "") | |
| 48 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 49 | + | prep := awaitRecord(c, r, run.GetId(), "prep/j") | |
| 50 | + | assert.Empty(t, prep.GetSecrets()) | |
| 51 | + | r.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"payload": "\x01UNPASSED\x02"}) | |
| 52 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |
| 53 | + | assert.NotEqual(t, "private-value", j.GetSecrets()["TOKEN"], "callee output must not mint a secret reference") | |
| 54 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestCIReusableSecretExpressions(t *testing.T) { | |
| 58 | + | for _, tt := range []struct{ name, value, want string }{ | |
| 59 | + | {"literal", "literal-value", "literal-value"}, | |
| 60 | + | {"dynamic", "${{ secrets[vars.KEY] }}", "private-value"}, | |
| 61 | + | {"conditional", "${{ secrets.SOURCE == 'private-value' && 'yes' || 'no' }}", "yes"}, | |
| 62 | + | {"function", "${{ format('{0}-x', secrets.SOURCE) }}", "private-value-x"}, | |
| 63 | + | {"text around", "pre-${{ secrets.SOURCE }}-post", "pre-private-value-post"}, | |
| 64 | + | } { | |
| 65 | + | t.Run(tt.name, func(t *testing.T) { | |
| 66 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 67 | + | c.setSecret(repoScope("proj"), "SOURCE", "private-value") | |
| 68 | + | c.setVar(repoScope("proj"), "KEY", "SOURCE") | |
| 69 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + tt.value + "\n" | |
| 70 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}, "") | |
| 71 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 72 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |
| 73 | + | assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"]) | |
| 74 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestCIReusableCallMaxParallel(t *testing.T) { | |
| 80 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 81 | + | caller := `on: push | |
| 82 | + | jobs: | |
| 83 | + | c: | |
| 84 | + | strategy: | |
| 85 | + | max-parallel: 1 | |
| 86 | + | matrix: | |
| 87 | + | v: [a,b] | |
| 88 | + | uses: ./.github/workflows/callee.yml | |
| 89 | + | ` | |
| 90 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": ghCallee}, "") | |
| 91 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 92 | + | j := awaitRecord(c, r, run.GetId(), "c/test") | |
| 93 | + | second, err := r.fetch("") | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | assert.Nil(t, second, "max-parallel: 1 must not assign the second call while the first is running") | |
| 96 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 97 | + | next := awaitRecord(c, r, run.GetId(), "c/test") | |
| 98 | + | assert.NotEqual(t, j.GetJobId(), next.GetJobId(), "the second call starts once the first has ended") | |
| 99 | + | r.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 100 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion()) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestCIReusableOneResolutionPerAttempt(t *testing.T) { | |
| 104 | + | remote := newFakeRemote(t) | |
| 105 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaMain)) | |
| 106 | + | wf := &fakeWorkflows{m: mocks.NewWorkflowFetcher(t)} | |
| 107 | + | wf.m.EXPECT().FetchWorkflow(mock.Anything, "acme", "shared", mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _, _, sha, path string, _ int64) ([]byte, error) { | |
| 108 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaOther)) | |
| 109 | + | return []byte(ghCallee), nil | |
| 110 | + | }).Maybe() | |
| 111 | + | c, r := newReusableHarness(t, remote, wf) | |
| 112 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n a:\n uses: acme/shared/.github/workflows/a.yml@main\n b:\n uses: acme/shared/.github/workflows/b.yml@main\n"}, "") | |
| 113 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 114 | + | for range 2 { | |
| 115 | + | j := r.await() | |
| 116 | + | assert.Contains(t, j.GetContexts(), `"workflow_sha":"`+shaMain+`"`, "the first resolution must pin all references of the attempt") | |
| 117 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 118 | + | } | |
| 119 | + | assert.Equal(t, 1, remote.count()) | |
| 120 | + | c.waitRunDone(run.GetId()) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestCIReusableFailedRerunKeepsTheCallee(t *testing.T) { | |
| 124 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |
| 125 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaMain)) | |
| 126 | + | wf.set("acme/shared@"+shaMain+":.github/workflows/a.yml", ghCallee) | |
| 127 | + | wf.set("acme/shared@"+shaOther+":.github/workflows/a.yml", ghCallee) | |
| 128 | + | c, r := newReusableHarness(t, remote, wf) | |
| 129 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n a:\n uses: acme/shared/.github/workflows/a.yml@main\n"}, "") | |
| 130 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 131 | + | j := r.await() | |
| 132 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil) | |
| 133 | + | c.waitRunDone(run.GetId()) | |
| 134 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaOther)) | |
| 135 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId(), FailedOnly: true})) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | again := r.await() | |
| 138 | + | assert.Contains(t, again.GetContexts(), `"workflow_sha":"`+shaMain+`"`, "failed-job reruns must reuse the first attempt's callee commit") | |
| 139 | + | r.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestCIReusableJobRecordBudget(t *testing.T) { | |
| 143 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 144 | + | vals := func(n int) string { | |
| 145 | + | v := make([]string, n) | |
| 146 | + | for i := range v { | |
| 147 | + | v[i] = fmt.Sprint(i) | |
| 148 | + | } | |
| 149 | + | return "[" + strings.Join(v, ",") + "]" | |
| 150 | + | } | |
| 151 | + | caller := "on: push\njobs:\n c:\n strategy:\n matrix:\n v: " + vals(33) + "\n uses: ./.github/workflows/callee.yml\n" | |
| 152 | + | callee := "on: workflow_call\njobs:\n j:\n strategy:\n matrix:\n v: " + vals(32) + "\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |
| 153 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "") | |
| 154 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 155 | + | const budgetText = "Workflows may not contain more than 1000 jobs across all referenced files" | |
| 156 | + | c.waitFor("the budget refusal", func() bool { | |
| 157 | + | for _, j := range c.getRun(run.GetId()).GetJobs() { | |
| 158 | + | if strings.Contains(j.GetError(), budgetText) { | |
| 159 | + | return true | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | return false | |
| 163 | + | }) | |
| 164 | + | assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), usecase.MaxCIChecks, "expanded records obey the job-count limit") | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // The ordinary matrices of a workflow share the same budget. | |
| 168 | + | func TestCIJobRecordBudgetOfMatrices(t *testing.T) { | |
| 169 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 170 | + | v := make([]string, 200) | |
| 171 | + | for i := range v { | |
| 172 | + | v[i] = fmt.Sprint(i) | |
| 173 | + | } | |
| 174 | + | wf := "on: push\njobs:\n" | |
| 175 | + | for _, id := range []string{"a", "b", "c", "d", "e", "f"} { | |
| 176 | + | wf += " " + id + ":\n strategy:\n matrix:\n v: [" + strings.Join(v, ",") + "]\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |
| 177 | + | } | |
| 178 | + | c.push("main", map[string]string{".github/workflows/ci.yml": wf}, "") | |
| 179 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 180 | + | c.waitFor("a job fails on the budget", func() bool { | |
| 181 | + | for _, j := range c.getRun(run.GetId()).GetJobs() { | |
| 182 | + | if strings.Contains(j.GetError(), "Workflows may not contain more than 1000 jobs across all referenced files") { | |
| 183 | + | return true | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | return false | |
| 187 | + | }) | |
| 188 | + | assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), usecase.MaxCIChecks) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestCIReusableCachedGitHubResolutionDoesNotBypassErmite(t *testing.T) { | |
| 192 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |
| 193 | + | remote.set("owner/shared", ptrRef("refs/heads/main", shaMain)) | |
| 194 | + | wf.set("owner/shared@"+shaMain+":.github/workflows/a.yml", ghCallee) | |
| 195 | + | c, r := newReusableHarness(t, remote, wf) | |
| 196 | + | token, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 197 | + | caller := "on: push\njobs:\n a:\n uses: owner/shared/.github/workflows/a.yml@main\n b:\n needs: a\n uses: owner/shared/.github/workflows/a.yml@main\n" | |
| 198 | + | c.pushAs(token, "main", map[string]string{".github/workflows/ci.yml": caller}) | |
| 199 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 200 | + | first := awaitRecord(c, r, run.GetId(), "a/test") | |
| 201 | + | c.seed("shared", domain.VisibilityPrivate) | |
| 202 | + | r.runJob(first, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 203 | + | second, err := r.fetch("") | |
| 204 | + | require.NoError(t, err) | |
| 205 | + | assert.True(t, second == nil, "must not use the cached GitHub source after an unreadable ermite name collision") | |
| 206 | + | if second != nil { | |
| 207 | + | r.runJob(second, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // S2: a literal in "secrets:" arrives although no stored secret resolves; a run | |
| 212 | + | // that is not trusted (a branch-limited token pushed it) gets nothing. | |
| 213 | + | func TestCIReusableLiteralSecretNeedsATrustedRun(t *testing.T) { | |
| 214 | + | for _, tt := range []struct { | |
| 215 | + | name string | |
| 216 | + | limited bool | |
| 217 | + | want string | |
| 218 | + | }{{"owner", false, "literal-value"}, {"limited token", true, ""}} { | |
| 219 | + | t.Run(tt.name, func(t *testing.T) { | |
| 220 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 221 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: literal-value\n" | |
| 222 | + | files := map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee} | |
| 223 | + | if tt.limited { | |
| 224 | + | tok, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |
| 225 | + | c.pushAs(tok, "main", files) | |
| 226 | + | } else { | |
| 227 | + | c.push("main", files, "") | |
| 228 | + | } | |
| 229 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 230 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |
| 231 | + | assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"]) | |
| 232 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 233 | + | }) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // T3: the name of a nested job carries every caller. | |
| 238 | + | func TestCIReusableNestedNames(t *testing.T) { | |
| 239 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 240 | + | leaf := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |
| 241 | + | mid := "on:\n workflow_call:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n" | |
| 242 | + | c.push("main", map[string]string{".github/workflows/leaf.yml": leaf, ".github/workflows/mid.yml": mid, ".github/workflows/ci.yml": "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n"}, "") | |
| 243 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 244 | + | j := awaitRecord(c, r, run.GetId(), "top/inner/j") | |
| 245 | + | names := map[string]string{} | |
| 246 | + | for _, x := range c.getRun(run.GetId()).GetJobs() { | |
| 247 | + | names[x.GetKey()] = x.GetName() | |
| 248 | + | } | |
| 249 | + | assert.Equal(t, map[string]string{"top": "top", "top/inner": "top / inner", "top/inner/j": "top / inner / j"}, names) | |
| 250 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // S1/S3: secrets pass through two levels of calls, each mapping evaluated at | |
| 254 | + | // its own level. | |
| 255 | + | func TestCIReusableSecretsThroughTwoLevels(t *testing.T) { | |
| 256 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 257 | + | c.setSecret(repoScope("proj"), "TOP", "top-value") | |
| 258 | + | leaf := "on:\n workflow_call:\n secrets:\n LEAF:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ secrets.LEAF }}\n" | |
| 259 | + | mid := "on:\n workflow_call:\n secrets:\n MID:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n secrets:\n LEAF: ${{ secrets.MID }}-leaf\n" | |
| 260 | + | c.push("main", map[string]string{".github/workflows/leaf.yml": leaf, ".github/workflows/mid.yml": mid, ".github/workflows/ci.yml": "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n secrets:\n MID: ${{ secrets.TOP }}\n"}, "") | |
| 261 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 262 | + | j := awaitRecord(c, r, run.GetId(), "top/inner/j") | |
| 263 | + | assert.Equal(t, map[string]string{"LEAF": "top-value-leaf"}, j.GetSecrets()) | |
| 264 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 265 | + | } | |
| @@ -0,0 +1,265 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 17 | + | "bnema.dev/ermite/internal/usecase" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestCIReusableSecretOutputCannotForgeAReference(t *testing.T) { | |||
| 21 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 22 | + | c.setSecret(repoScope("proj"), "UNPASSED", "private-value") | |||
| 23 | + | caller := `on: push | |||
| 24 | + | jobs: | |||
| 25 | + | prep: | |||
| 26 | + | uses: ./.github/workflows/producer.yml | |||
| 27 | + | call: | |||
| 28 | + | needs: prep | |||
| 29 | + | uses: ./.github/workflows/callee.yml | |||
| 30 | + | secrets: | |||
| 31 | + | TOKEN: ${{ needs.prep.outputs.payload }} | |||
| 32 | + | ` | |||
| 33 | + | producer := `on: | |||
| 34 | + | workflow_call: | |||
| 35 | + | outputs: | |||
| 36 | + | payload: | |||
| 37 | + | value: ${{ jobs.j.outputs.payload }} | |||
| 38 | + | jobs: | |||
| 39 | + | j: | |||
| 40 | + | runs-on: ubuntu-latest | |||
| 41 | + | outputs: | |||
| 42 | + | payload: ${{ steps.s.outputs.payload }} | |||
| 43 | + | steps: | |||
| 44 | + | - id: s | |||
| 45 | + | run: echo | |||
| 46 | + | ` | |||
| 47 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/producer.yml": producer, ".github/workflows/callee.yml": secretsCallee}, "") | |||
| 48 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 49 | + | prep := awaitRecord(c, r, run.GetId(), "prep/j") | |||
| 50 | + | assert.Empty(t, prep.GetSecrets()) | |||
| 51 | + | r.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"payload": "\x01UNPASSED\x02"}) | |||
| 52 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |||
| 53 | + | assert.NotEqual(t, "private-value", j.GetSecrets()["TOKEN"], "callee output must not mint a secret reference") | |||
| 54 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestCIReusableSecretExpressions(t *testing.T) { | |||
| 58 | + | for _, tt := range []struct{ name, value, want string }{ | |||
| 59 | + | {"literal", "literal-value", "literal-value"}, | |||
| 60 | + | {"dynamic", "${{ secrets[vars.KEY] }}", "private-value"}, | |||
| 61 | + | {"conditional", "${{ secrets.SOURCE == 'private-value' && 'yes' || 'no' }}", "yes"}, | |||
| 62 | + | {"function", "${{ format('{0}-x', secrets.SOURCE) }}", "private-value-x"}, | |||
| 63 | + | {"text around", "pre-${{ secrets.SOURCE }}-post", "pre-private-value-post"}, | |||
| 64 | + | } { | |||
| 65 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 66 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 67 | + | c.setSecret(repoScope("proj"), "SOURCE", "private-value") | |||
| 68 | + | c.setVar(repoScope("proj"), "KEY", "SOURCE") | |||
| 69 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + tt.value + "\n" | |||
| 70 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}, "") | |||
| 71 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 72 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |||
| 73 | + | assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"]) | |||
| 74 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestCIReusableCallMaxParallel(t *testing.T) { | |||
| 80 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 81 | + | caller := `on: push | |||
| 82 | + | jobs: | |||
| 83 | + | c: | |||
| 84 | + | strategy: | |||
| 85 | + | max-parallel: 1 | |||
| 86 | + | matrix: | |||
| 87 | + | v: [a,b] | |||
| 88 | + | uses: ./.github/workflows/callee.yml | |||
| 89 | + | ` | |||
| 90 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": ghCallee}, "") | |||
| 91 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 92 | + | j := awaitRecord(c, r, run.GetId(), "c/test") | |||
| 93 | + | second, err := r.fetch("") | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | assert.Nil(t, second, "max-parallel: 1 must not assign the second call while the first is running") | |||
| 96 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 97 | + | next := awaitRecord(c, r, run.GetId(), "c/test") | |||
| 98 | + | assert.NotEqual(t, j.GetJobId(), next.GetJobId(), "the second call starts once the first has ended") | |||
| 99 | + | r.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 100 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion()) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestCIReusableOneResolutionPerAttempt(t *testing.T) { | |||
| 104 | + | remote := newFakeRemote(t) | |||
| 105 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaMain)) | |||
| 106 | + | wf := &fakeWorkflows{m: mocks.NewWorkflowFetcher(t)} | |||
| 107 | + | wf.m.EXPECT().FetchWorkflow(mock.Anything, "acme", "shared", mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _, _, sha, path string, _ int64) ([]byte, error) { | |||
| 108 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaOther)) | |||
| 109 | + | return []byte(ghCallee), nil | |||
| 110 | + | }).Maybe() | |||
| 111 | + | c, r := newReusableHarness(t, remote, wf) | |||
| 112 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n a:\n uses: acme/shared/.github/workflows/a.yml@main\n b:\n uses: acme/shared/.github/workflows/b.yml@main\n"}, "") | |||
| 113 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 114 | + | for range 2 { | |||
| 115 | + | j := r.await() | |||
| 116 | + | assert.Contains(t, j.GetContexts(), `"workflow_sha":"`+shaMain+`"`, "the first resolution must pin all references of the attempt") | |||
| 117 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 118 | + | } | |||
| 119 | + | assert.Equal(t, 1, remote.count()) | |||
| 120 | + | c.waitRunDone(run.GetId()) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestCIReusableFailedRerunKeepsTheCallee(t *testing.T) { | |||
| 124 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |||
| 125 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaMain)) | |||
| 126 | + | wf.set("acme/shared@"+shaMain+":.github/workflows/a.yml", ghCallee) | |||
| 127 | + | wf.set("acme/shared@"+shaOther+":.github/workflows/a.yml", ghCallee) | |||
| 128 | + | c, r := newReusableHarness(t, remote, wf) | |||
| 129 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n a:\n uses: acme/shared/.github/workflows/a.yml@main\n"}, "") | |||
| 130 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 131 | + | j := r.await() | |||
| 132 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil) | |||
| 133 | + | c.waitRunDone(run.GetId()) | |||
| 134 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaOther)) | |||
| 135 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId(), FailedOnly: true})) | |||
| 136 | + | require.NoError(t, err) | |||
| 137 | + | again := r.await() | |||
| 138 | + | assert.Contains(t, again.GetContexts(), `"workflow_sha":"`+shaMain+`"`, "failed-job reruns must reuse the first attempt's callee commit") | |||
| 139 | + | r.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestCIReusableJobRecordBudget(t *testing.T) { | |||
| 143 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 144 | + | vals := func(n int) string { | |||
| 145 | + | v := make([]string, n) | |||
| 146 | + | for i := range v { | |||
| 147 | + | v[i] = fmt.Sprint(i) | |||
| 148 | + | } | |||
| 149 | + | return "[" + strings.Join(v, ",") + "]" | |||
| 150 | + | } | |||
| 151 | + | caller := "on: push\njobs:\n c:\n strategy:\n matrix:\n v: " + vals(33) + "\n uses: ./.github/workflows/callee.yml\n" | |||
| 152 | + | callee := "on: workflow_call\njobs:\n j:\n strategy:\n matrix:\n v: " + vals(32) + "\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |||
| 153 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "") | |||
| 154 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 155 | + | const budgetText = "Workflows may not contain more than 1000 jobs across all referenced files" | |||
| 156 | + | c.waitFor("the budget refusal", func() bool { | |||
| 157 | + | for _, j := range c.getRun(run.GetId()).GetJobs() { | |||
| 158 | + | if strings.Contains(j.GetError(), budgetText) { | |||
| 159 | + | return true | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | return false | |||
| 163 | + | }) | |||
| 164 | + | assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), usecase.MaxCIChecks, "expanded records obey the job-count limit") | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // The ordinary matrices of a workflow share the same budget. | |||
| 168 | + | func TestCIJobRecordBudgetOfMatrices(t *testing.T) { | |||
| 169 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 170 | + | v := make([]string, 200) | |||
| 171 | + | for i := range v { | |||
| 172 | + | v[i] = fmt.Sprint(i) | |||
| 173 | + | } | |||
| 174 | + | wf := "on: push\njobs:\n" | |||
| 175 | + | for _, id := range []string{"a", "b", "c", "d", "e", "f"} { | |||
| 176 | + | wf += " " + id + ":\n strategy:\n matrix:\n v: [" + strings.Join(v, ",") + "]\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |||
| 177 | + | } | |||
| 178 | + | c.push("main", map[string]string{".github/workflows/ci.yml": wf}, "") | |||
| 179 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 180 | + | c.waitFor("a job fails on the budget", func() bool { | |||
| 181 | + | for _, j := range c.getRun(run.GetId()).GetJobs() { | |||
| 182 | + | if strings.Contains(j.GetError(), "Workflows may not contain more than 1000 jobs across all referenced files") { | |||
| 183 | + | return true | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | return false | |||
| 187 | + | }) | |||
| 188 | + | assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), usecase.MaxCIChecks) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestCIReusableCachedGitHubResolutionDoesNotBypassErmite(t *testing.T) { | |||
| 192 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |||
| 193 | + | remote.set("owner/shared", ptrRef("refs/heads/main", shaMain)) | |||
| 194 | + | wf.set("owner/shared@"+shaMain+":.github/workflows/a.yml", ghCallee) | |||
| 195 | + | c, r := newReusableHarness(t, remote, wf) | |||
| 196 | + | token, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |||
| 197 | + | caller := "on: push\njobs:\n a:\n uses: owner/shared/.github/workflows/a.yml@main\n b:\n needs: a\n uses: owner/shared/.github/workflows/a.yml@main\n" | |||
| 198 | + | c.pushAs(token, "main", map[string]string{".github/workflows/ci.yml": caller}) | |||
| 199 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 200 | + | first := awaitRecord(c, r, run.GetId(), "a/test") | |||
| 201 | + | c.seed("shared", domain.VisibilityPrivate) | |||
| 202 | + | r.runJob(first, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 203 | + | second, err := r.fetch("") | |||
| 204 | + | require.NoError(t, err) | |||
| 205 | + | assert.True(t, second == nil, "must not use the cached GitHub source after an unreadable ermite name collision") | |||
| 206 | + | if second != nil { | |||
| 207 | + | r.runJob(second, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // S2: a literal in "secrets:" arrives although no stored secret resolves; a run | |||
| 212 | + | // that is not trusted (a branch-limited token pushed it) gets nothing. | |||
| 213 | + | func TestCIReusableLiteralSecretNeedsATrustedRun(t *testing.T) { | |||
| 214 | + | for _, tt := range []struct { | |||
| 215 | + | name string | |||
| 216 | + | limited bool | |||
| 217 | + | want string | |||
| 218 | + | }{{"owner", false, "literal-value"}, {"limited token", true, ""}} { | |||
| 219 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 220 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 221 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: literal-value\n" | |||
| 222 | + | files := map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee} | |||
| 223 | + | if tt.limited { | |||
| 224 | + | tok, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |||
| 225 | + | c.pushAs(tok, "main", files) | |||
| 226 | + | } else { | |||
| 227 | + | c.push("main", files, "") | |||
| 228 | + | } | |||
| 229 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 230 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |||
| 231 | + | assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"]) | |||
| 232 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 233 | + | }) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // T3: the name of a nested job carries every caller. | |||
| 238 | + | func TestCIReusableNestedNames(t *testing.T) { | |||
| 239 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 240 | + | leaf := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |||
| 241 | + | mid := "on:\n workflow_call:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n" | |||
| 242 | + | c.push("main", map[string]string{".github/workflows/leaf.yml": leaf, ".github/workflows/mid.yml": mid, ".github/workflows/ci.yml": "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n"}, "") | |||
| 243 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 244 | + | j := awaitRecord(c, r, run.GetId(), "top/inner/j") | |||
| 245 | + | names := map[string]string{} | |||
| 246 | + | for _, x := range c.getRun(run.GetId()).GetJobs() { | |||
| 247 | + | names[x.GetKey()] = x.GetName() | |||
| 248 | + | } | |||
| 249 | + | assert.Equal(t, map[string]string{"top": "top", "top/inner": "top / inner", "top/inner/j": "top / inner / j"}, names) | |||
| 250 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // S1/S3: secrets pass through two levels of calls, each mapping evaluated at | |||
| 254 | + | // its own level. | |||
| 255 | + | func TestCIReusableSecretsThroughTwoLevels(t *testing.T) { | |||
| 256 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 257 | + | c.setSecret(repoScope("proj"), "TOP", "top-value") | |||
| 258 | + | leaf := "on:\n workflow_call:\n secrets:\n LEAF:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ secrets.LEAF }}\n" | |||
| 259 | + | mid := "on:\n workflow_call:\n secrets:\n MID:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n secrets:\n LEAF: ${{ secrets.MID }}-leaf\n" | |||
| 260 | + | c.push("main", map[string]string{".github/workflows/leaf.yml": leaf, ".github/workflows/mid.yml": mid, ".github/workflows/ci.yml": "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n secrets:\n MID: ${{ secrets.TOP }}\n"}, "") | |||
| 261 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 262 | + | j := awaitRecord(c, r, run.GetId(), "top/inner/j") | |||
| 263 | + | assert.Equal(t, map[string]string{"LEAF": "top-value-leaf"}, j.GetSecrets()) | |||
| 264 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 265 | + | } | |||
A internal/app/ci_reusable_secrets_test.go +259 -0
| @@ -0,0 +1,259 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const producerWorkflow = `on: | |
| 19 | + | workflow_call: | |
| 20 | + | outputs: | |
| 21 | + | payload: | |
| 22 | + | value: ${{ jobs.j.outputs.payload }} | |
| 23 | + | jobs: | |
| 24 | + | j: | |
| 25 | + | runs-on: ubuntu-latest | |
| 26 | + | outputs: | |
| 27 | + | payload: ${{ steps.s.outputs.payload }} | |
| 28 | + | steps: | |
| 29 | + | - id: s | |
| 30 | + | run: echo | |
| 31 | + | ` | |
| 32 | + | ||
| 33 | + | // secretCall runs a push whose caller maps TOKEN with value and returns the job | |
| 34 | + | // of the callee that was assigned. | |
| 35 | + | func secretCall(t *testing.T, setup func(c *ciHarness), value string) (*ciHarness, *fakeRunner, *pb.Job) { | |
| 36 | + | t.Helper() | |
| 37 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 38 | + | if setup != nil { | |
| 39 | + | setup(c) | |
| 40 | + | } | |
| 41 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + value + "\n" | |
| 42 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}, "") | |
| 43 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 44 | + | return c, r, awaitRecord(c, r, run.GetId(), "call/j") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // GO-009: a string that an expression produces is data. It is not read again as | |
| 48 | + | // an expression, so it cannot name a secret. | |
| 49 | + | func TestCIReusableEscapedExpressionStaysALiteral(t *testing.T) { | |
| 50 | + | c, r, j := secretCall(t, func(c *ciHarness) { | |
| 51 | + | c.setSecret(repoScope("proj"), "UNPASSED", "private-value") | |
| 52 | + | }, "${{ '${{ secrets.UNPASSED }}' }}") | |
| 53 | + | assert.Equal(t, "${{ secrets.UNPASSED }}", j.GetSecrets()["TOKEN"]) | |
| 54 | + | assert.NotContains(t, c.logs.String(), "private-value") | |
| 55 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // GO-009: nor does a name taken from a context select a secret. | |
| 59 | + | func TestCIReusableContextDataCannotSelectASecret(t *testing.T) { | |
| 60 | + | for _, tt := range []struct{ name, token, payload, want string }{ | |
| 61 | + | {"a text that looks like an expression", "${{ needs.prep.outputs.payload }}", "${{ secrets.UNPASSED }}", "${{ secrets.UNPASSED }}"}, | |
| 62 | + | {"a former marker", "${{ needs.prep.outputs.payload }}", "\x01UNPASSED\x02", "\x01UNPASSED\x02"}, | |
| 63 | + | {"an escaped expression over json", "${{ '${{ secrets[fromJSON(needs.prep.outputs.payload).name] }}' }}", `{"name":"UNPASSED"}`, "${{ secrets[fromJSON(needs.prep.outputs.payload).name] }}"}, | |
| 64 | + | } { | |
| 65 | + | t.Run(tt.name, func(t *testing.T) { | |
| 66 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 67 | + | c.setSecret(repoScope("proj"), "UNPASSED", "private-value") | |
| 68 | + | caller := "on: push\njobs:\n prep:\n uses: ./.github/workflows/producer.yml\n call:\n needs: prep\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + tt.token + "\n" | |
| 69 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/producer.yml": producerWorkflow, ".github/workflows/callee.yml": secretsCallee}, "") | |
| 70 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 71 | + | prep := awaitRecord(c, r, run.GetId(), "prep/j") | |
| 72 | + | assert.Empty(t, prep.GetSecrets()) | |
| 73 | + | r.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"payload": tt.payload}) | |
| 74 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |
| 75 | + | assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"]) | |
| 76 | + | assert.NotEqual(t, "private-value", j.GetSecrets()["TOKEN"]) | |
| 77 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // GO-009: an expression that selects a secret by data of the caller (an input of | |
| 83 | + | // the workflow, here) does use the secret it names. | |
| 84 | + | func TestCIReusableMappingUsesPlainContexts(t *testing.T) { | |
| 85 | + | _, r, j := secretCall(t, func(c *ciHarness) { | |
| 86 | + | c.setSecret(repoScope("proj"), "SOURCE", "private-value") | |
| 87 | + | c.setVar(repoScope("proj"), "KEY", "SOURCE") | |
| 88 | + | }, "${{ secrets[vars.KEY] }}") | |
| 89 | + | assert.Equal(t, "private-value", j.GetSecrets()["TOKEN"]) | |
| 90 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // GO-010: an expression error never reaches the log: format() puts its | |
| 94 | + | // arguments, here a secret, in its message. | |
| 95 | + | func TestCIReusableSecretEvaluationErrorIsNotLogged(t *testing.T) { | |
| 96 | + | secret := "private-value{bad-format}" | |
| 97 | + | c, r, j := secretCall(t, func(c *ciHarness) { | |
| 98 | + | c.setSecret(repoScope("proj"), "SOURCE", secret) | |
| 99 | + | }, "${{ format(secrets.SOURCE) }}") | |
| 100 | + | assert.Empty(t, j.GetSecrets()["TOKEN"]) | |
| 101 | + | logs := c.logs.String() | |
| 102 | + | assert.NotContains(t, logs, secret) | |
| 103 | + | assert.NotContains(t, logs, "private-value") | |
| 104 | + | assert.Contains(t, logs, "could not evaluate", "the mapping is named, with a fixed reason") | |
| 105 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // GO-011: planning does not evaluate the mapping, so a value that needs a real | |
| 109 | + | // secret (here a JSON one) is not refused for lack of it. | |
| 110 | + | func TestCIReusableMappingNeedsNoSecretAtPlanning(t *testing.T) { | |
| 111 | + | c, r, j := secretCall(t, func(c *ciHarness) { | |
| 112 | + | c.setSecret(repoScope("proj"), "JSON", `{"token":"private-value"}`) | |
| 113 | + | }, "${{ fromJSON(secrets.JSON).token }}") | |
| 114 | + | assert.Equal(t, "private-value", j.GetSecrets()["TOKEN"]) | |
| 115 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 116 | + | _ = c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // GO-012: the mapping of a call reads the vars of the calling scope, not those | |
| 120 | + | // of the environment of the job that is assigned. | |
| 121 | + | func TestCIReusableMappingUsesTheCallersVars(t *testing.T) { | |
| 122 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 123 | + | c.setSecret(repoScope("proj"), "PUBLIC", "public-value") | |
| 124 | + | c.setSecret(repoScope("proj"), "PRIVATE", "private-value") | |
| 125 | + | c.setVar(repoScope("proj"), "KEY", "PUBLIC") | |
| 126 | + | _, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "prod"})) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | c.setVar(envScope("proj", "prod"), "KEY", "PRIVATE") | |
| 129 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: ${{ secrets[vars.KEY] }}\n" | |
| 130 | + | callee := strings.Replace(secretsCallee, " runs-on: ubuntu-latest", " runs-on: ubuntu-latest\n environment: prod", 1) | |
| 131 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "") | |
| 132 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 133 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |
| 134 | + | assert.Equal(t, "public-value", j.GetSecrets()["TOKEN"]) | |
| 135 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // An input handed down as an escaped expression is data in a callee that | |
| 139 | + | // inherits secrets, and an untrusted run gets nothing. | |
| 140 | + | func TestCIReusableInheritScopeTrustAndInputData(t *testing.T) { | |
| 141 | + | for _, limited := range []bool{false, true} { | |
| 142 | + | t.Run(map[bool]string{false: "trusted", true: "untrusted"}[limited], func(t *testing.T) { | |
| 143 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 144 | + | c.setSecret(repoScope("proj"), "TOP", "top-value") | |
| 145 | + | c.setSecret(repoScope("proj"), "UNPASSED", "private-value") | |
| 146 | + | leaf := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ toJSON(secrets) }}\n" | |
| 147 | + | mid := "on:\n workflow_call:\n inputs:\n text:\n type: string\n secrets:\n MID:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n secrets: inherit\n" | |
| 148 | + | caller := "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n with:\n text: '${{ ''${{ secrets.UNPASSED }}'' }}'\n secrets:\n MID: ${{ secrets.TOP }}\n" | |
| 149 | + | files := map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/mid.yml": mid, ".github/workflows/leaf.yml": leaf} | |
| 150 | + | if limited { | |
| 151 | + | tok, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |
| 152 | + | c.pushAs(tok, "main", files) | |
| 153 | + | } else { | |
| 154 | + | c.push("main", files, "") | |
| 155 | + | } | |
| 156 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 157 | + | j := awaitRecord(c, r, run.GetId(), "top/inner/j") | |
| 158 | + | if limited { | |
| 159 | + | assert.Empty(t, j.GetSecrets()) | |
| 160 | + | } else { | |
| 161 | + | assert.Equal(t, map[string]string{"MID": "top-value"}, j.GetSecrets()) | |
| 162 | + | } | |
| 163 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 164 | + | }) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // A secret cannot be handed to the callee as an input. | |
| 169 | + | func TestCIReusableSecretsAreRefusedInWith(t *testing.T) { | |
| 170 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 171 | + | c.setSecret(repoScope("proj"), "SOURCE", "private-value") | |
| 172 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n with:\n go-version: ${{ secrets.SOURCE }}\n" | |
| 173 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": ghCallee}, "") | |
| 174 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 175 | + | c.waitRunDone(run.GetId()) | |
| 176 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.getRun(run.GetId()).GetRun().GetConclusion()) | |
| 177 | + | assert.NotContains(t, c.logs.String(), "private-value") | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // A re-run of everything resolves the reference of a called workflow afresh. | |
| 181 | + | func TestCIReusableRerunAllRefreshesTheCallee(t *testing.T) { | |
| 182 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |
| 183 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaMain)) | |
| 184 | + | wf.set("acme/shared@"+shaMain+":.github/workflows/a.yml", ghCallee) | |
| 185 | + | wf.set("acme/shared@"+shaOther+":.github/workflows/a.yml", ghCallee) | |
| 186 | + | c, r := newReusableHarness(t, remote, wf) | |
| 187 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n a:\n uses: acme/shared/.github/workflows/a.yml@main\n"}, "") | |
| 188 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 189 | + | first := r.await() | |
| 190 | + | assert.Contains(t, first.GetContexts(), `"workflow_sha":"`+shaMain+`"`) | |
| 191 | + | r.runJob(first, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil) | |
| 192 | + | c.waitRunDone(run.GetId()) | |
| 193 | + | ||
| 194 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaOther)) | |
| 195 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId()})) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | again := r.await() | |
| 198 | + | assert.Contains(t, again.GetContexts(), `"workflow_sha":"`+shaOther+`"`, "the reference is resolved again") | |
| 199 | + | r.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 200 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion()) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // A matrix of calls with max-parallel and both concurrency groups progresses | |
| 204 | + | // one call at a time to the end, and the groups are free for the next run. | |
| 205 | + | func TestCIReusableMaxParallelWithBothGroups(t *testing.T) { | |
| 206 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 207 | + | caller := "on: push\njobs:\n c:\n strategy:\n max-parallel: 1\n matrix:\n v: [a, b, c]\n concurrency: caller-group\n uses: ./.github/workflows/callee.yml\n" | |
| 208 | + | callee := strings.Replace(ghCallee, "jobs:\n", "concurrency: callee-group\njobs:\n", 1) | |
| 209 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "") | |
| 210 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 211 | + | for range 3 { | |
| 212 | + | j := awaitRecord(c, r, run.GetId(), "c/test") | |
| 213 | + | c.forge.CI.ReconcileConcurrency(c.ctx) | |
| 214 | + | second, err := r.fetch("") | |
| 215 | + | require.NoError(t, err) | |
| 216 | + | assert.Nil(t, second, "one call at a time") | |
| 217 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 218 | + | } | |
| 219 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion()) | |
| 220 | + | c.push("main", map[string]string{"x": "second"}, "") | |
| 221 | + | next := c.awaitRunOf(".github/workflows/ci.yml", run.GetId()) | |
| 222 | + | for range 3 { | |
| 223 | + | j := awaitRecord(c, r, next.GetId(), "c/test") | |
| 224 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 225 | + | } | |
| 226 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(next.GetId()).GetConclusion()) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // The job-record budget holds again after a re-run of the failed jobs. | |
| 230 | + | func TestCIReusableBudgetHoldsOnRerun(t *testing.T) { | |
| 231 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 232 | + | vals := func(n int) string { | |
| 233 | + | v := make([]string, n) | |
| 234 | + | for i := range v { | |
| 235 | + | v[i] = fmt.Sprint(i) | |
| 236 | + | } | |
| 237 | + | return "[" + strings.Join(v, ",") + "]" | |
| 238 | + | } | |
| 239 | + | caller := "on: push\njobs:\n c:\n strategy:\n matrix:\n v: " + vals(33) + "\n uses: ./.github/workflows/callee.yml\n" | |
| 240 | + | callee := "on: workflow_call\njobs:\n j:\n strategy:\n matrix:\n v: " + vals(32) + "\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |
| 241 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "") | |
| 242 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 243 | + | refused := func() bool { | |
| 244 | + | for _, j := range c.getRun(run.GetId()).GetJobs() { | |
| 245 | + | if strings.Contains(j.GetError(), "Workflows may not contain more than 1000 jobs across all referenced files") { | |
| 246 | + | return true | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | return false | |
| 250 | + | } | |
| 251 | + | c.waitFor("the budget refusal", refused) | |
| 252 | + | assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), 1000) | |
| 253 | + | _, _ = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()})) | |
| 254 | + | c.waitRunDone(run.GetId()) | |
| 255 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId(), FailedOnly: true})) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | c.waitFor("the refusal again", refused) | |
| 258 | + | assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), 1000) | |
| 259 | + | } | |
| @@ -0,0 +1,259 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const producerWorkflow = `on: | |||
| 19 | + | workflow_call: | |||
| 20 | + | outputs: | |||
| 21 | + | payload: | |||
| 22 | + | value: ${{ jobs.j.outputs.payload }} | |||
| 23 | + | jobs: | |||
| 24 | + | j: | |||
| 25 | + | runs-on: ubuntu-latest | |||
| 26 | + | outputs: | |||
| 27 | + | payload: ${{ steps.s.outputs.payload }} | |||
| 28 | + | steps: | |||
| 29 | + | - id: s | |||
| 30 | + | run: echo | |||
| 31 | + | ` | |||
| 32 | + | ||||
| 33 | + | // secretCall runs a push whose caller maps TOKEN with value and returns the job | |||
| 34 | + | // of the callee that was assigned. | |||
| 35 | + | func secretCall(t *testing.T, setup func(c *ciHarness), value string) (*ciHarness, *fakeRunner, *pb.Job) { | |||
| 36 | + | t.Helper() | |||
| 37 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 38 | + | if setup != nil { | |||
| 39 | + | setup(c) | |||
| 40 | + | } | |||
| 41 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + value + "\n" | |||
| 42 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}, "") | |||
| 43 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 44 | + | return c, r, awaitRecord(c, r, run.GetId(), "call/j") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // GO-009: a string that an expression produces is data. It is not read again as | |||
| 48 | + | // an expression, so it cannot name a secret. | |||
| 49 | + | func TestCIReusableEscapedExpressionStaysALiteral(t *testing.T) { | |||
| 50 | + | c, r, j := secretCall(t, func(c *ciHarness) { | |||
| 51 | + | c.setSecret(repoScope("proj"), "UNPASSED", "private-value") | |||
| 52 | + | }, "${{ '${{ secrets.UNPASSED }}' }}") | |||
| 53 | + | assert.Equal(t, "${{ secrets.UNPASSED }}", j.GetSecrets()["TOKEN"]) | |||
| 54 | + | assert.NotContains(t, c.logs.String(), "private-value") | |||
| 55 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // GO-009: nor does a name taken from a context select a secret. | |||
| 59 | + | func TestCIReusableContextDataCannotSelectASecret(t *testing.T) { | |||
| 60 | + | for _, tt := range []struct{ name, token, payload, want string }{ | |||
| 61 | + | {"a text that looks like an expression", "${{ needs.prep.outputs.payload }}", "${{ secrets.UNPASSED }}", "${{ secrets.UNPASSED }}"}, | |||
| 62 | + | {"a former marker", "${{ needs.prep.outputs.payload }}", "\x01UNPASSED\x02", "\x01UNPASSED\x02"}, | |||
| 63 | + | {"an escaped expression over json", "${{ '${{ secrets[fromJSON(needs.prep.outputs.payload).name] }}' }}", `{"name":"UNPASSED"}`, "${{ secrets[fromJSON(needs.prep.outputs.payload).name] }}"}, | |||
| 64 | + | } { | |||
| 65 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 66 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 67 | + | c.setSecret(repoScope("proj"), "UNPASSED", "private-value") | |||
| 68 | + | caller := "on: push\njobs:\n prep:\n uses: ./.github/workflows/producer.yml\n call:\n needs: prep\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + tt.token + "\n" | |||
| 69 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/producer.yml": producerWorkflow, ".github/workflows/callee.yml": secretsCallee}, "") | |||
| 70 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 71 | + | prep := awaitRecord(c, r, run.GetId(), "prep/j") | |||
| 72 | + | assert.Empty(t, prep.GetSecrets()) | |||
| 73 | + | r.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"payload": tt.payload}) | |||
| 74 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |||
| 75 | + | assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"]) | |||
| 76 | + | assert.NotEqual(t, "private-value", j.GetSecrets()["TOKEN"]) | |||
| 77 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // GO-009: an expression that selects a secret by data of the caller (an input of | |||
| 83 | + | // the workflow, here) does use the secret it names. | |||
| 84 | + | func TestCIReusableMappingUsesPlainContexts(t *testing.T) { | |||
| 85 | + | _, r, j := secretCall(t, func(c *ciHarness) { | |||
| 86 | + | c.setSecret(repoScope("proj"), "SOURCE", "private-value") | |||
| 87 | + | c.setVar(repoScope("proj"), "KEY", "SOURCE") | |||
| 88 | + | }, "${{ secrets[vars.KEY] }}") | |||
| 89 | + | assert.Equal(t, "private-value", j.GetSecrets()["TOKEN"]) | |||
| 90 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // GO-010: an expression error never reaches the log: format() puts its | |||
| 94 | + | // arguments, here a secret, in its message. | |||
| 95 | + | func TestCIReusableSecretEvaluationErrorIsNotLogged(t *testing.T) { | |||
| 96 | + | secret := "private-value{bad-format}" | |||
| 97 | + | c, r, j := secretCall(t, func(c *ciHarness) { | |||
| 98 | + | c.setSecret(repoScope("proj"), "SOURCE", secret) | |||
| 99 | + | }, "${{ format(secrets.SOURCE) }}") | |||
| 100 | + | assert.Empty(t, j.GetSecrets()["TOKEN"]) | |||
| 101 | + | logs := c.logs.String() | |||
| 102 | + | assert.NotContains(t, logs, secret) | |||
| 103 | + | assert.NotContains(t, logs, "private-value") | |||
| 104 | + | assert.Contains(t, logs, "could not evaluate", "the mapping is named, with a fixed reason") | |||
| 105 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // GO-011: planning does not evaluate the mapping, so a value that needs a real | |||
| 109 | + | // secret (here a JSON one) is not refused for lack of it. | |||
| 110 | + | func TestCIReusableMappingNeedsNoSecretAtPlanning(t *testing.T) { | |||
| 111 | + | c, r, j := secretCall(t, func(c *ciHarness) { | |||
| 112 | + | c.setSecret(repoScope("proj"), "JSON", `{"token":"private-value"}`) | |||
| 113 | + | }, "${{ fromJSON(secrets.JSON).token }}") | |||
| 114 | + | assert.Equal(t, "private-value", j.GetSecrets()["TOKEN"]) | |||
| 115 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 116 | + | _ = c | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // GO-012: the mapping of a call reads the vars of the calling scope, not those | |||
| 120 | + | // of the environment of the job that is assigned. | |||
| 121 | + | func TestCIReusableMappingUsesTheCallersVars(t *testing.T) { | |||
| 122 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 123 | + | c.setSecret(repoScope("proj"), "PUBLIC", "public-value") | |||
| 124 | + | c.setSecret(repoScope("proj"), "PRIVATE", "private-value") | |||
| 125 | + | c.setVar(repoScope("proj"), "KEY", "PUBLIC") | |||
| 126 | + | _, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "prod"})) | |||
| 127 | + | require.NoError(t, err) | |||
| 128 | + | c.setVar(envScope("proj", "prod"), "KEY", "PRIVATE") | |||
| 129 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: ${{ secrets[vars.KEY] }}\n" | |||
| 130 | + | callee := strings.Replace(secretsCallee, " runs-on: ubuntu-latest", " runs-on: ubuntu-latest\n environment: prod", 1) | |||
| 131 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "") | |||
| 132 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 133 | + | j := awaitRecord(c, r, run.GetId(), "call/j") | |||
| 134 | + | assert.Equal(t, "public-value", j.GetSecrets()["TOKEN"]) | |||
| 135 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // An input handed down as an escaped expression is data in a callee that | |||
| 139 | + | // inherits secrets, and an untrusted run gets nothing. | |||
| 140 | + | func TestCIReusableInheritScopeTrustAndInputData(t *testing.T) { | |||
| 141 | + | for _, limited := range []bool{false, true} { | |||
| 142 | + | t.Run(map[bool]string{false: "trusted", true: "untrusted"}[limited], func(t *testing.T) { | |||
| 143 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 144 | + | c.setSecret(repoScope("proj"), "TOP", "top-value") | |||
| 145 | + | c.setSecret(repoScope("proj"), "UNPASSED", "private-value") | |||
| 146 | + | leaf := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ toJSON(secrets) }}\n" | |||
| 147 | + | mid := "on:\n workflow_call:\n inputs:\n text:\n type: string\n secrets:\n MID:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n secrets: inherit\n" | |||
| 148 | + | caller := "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n with:\n text: '${{ ''${{ secrets.UNPASSED }}'' }}'\n secrets:\n MID: ${{ secrets.TOP }}\n" | |||
| 149 | + | files := map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/mid.yml": mid, ".github/workflows/leaf.yml": leaf} | |||
| 150 | + | if limited { | |||
| 151 | + | tok, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |||
| 152 | + | c.pushAs(tok, "main", files) | |||
| 153 | + | } else { | |||
| 154 | + | c.push("main", files, "") | |||
| 155 | + | } | |||
| 156 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 157 | + | j := awaitRecord(c, r, run.GetId(), "top/inner/j") | |||
| 158 | + | if limited { | |||
| 159 | + | assert.Empty(t, j.GetSecrets()) | |||
| 160 | + | } else { | |||
| 161 | + | assert.Equal(t, map[string]string{"MID": "top-value"}, j.GetSecrets()) | |||
| 162 | + | } | |||
| 163 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 164 | + | }) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // A secret cannot be handed to the callee as an input. | |||
| 169 | + | func TestCIReusableSecretsAreRefusedInWith(t *testing.T) { | |||
| 170 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 171 | + | c.setSecret(repoScope("proj"), "SOURCE", "private-value") | |||
| 172 | + | caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n with:\n go-version: ${{ secrets.SOURCE }}\n" | |||
| 173 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": ghCallee}, "") | |||
| 174 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 175 | + | c.waitRunDone(run.GetId()) | |||
| 176 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.getRun(run.GetId()).GetRun().GetConclusion()) | |||
| 177 | + | assert.NotContains(t, c.logs.String(), "private-value") | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // A re-run of everything resolves the reference of a called workflow afresh. | |||
| 181 | + | func TestCIReusableRerunAllRefreshesTheCallee(t *testing.T) { | |||
| 182 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |||
| 183 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaMain)) | |||
| 184 | + | wf.set("acme/shared@"+shaMain+":.github/workflows/a.yml", ghCallee) | |||
| 185 | + | wf.set("acme/shared@"+shaOther+":.github/workflows/a.yml", ghCallee) | |||
| 186 | + | c, r := newReusableHarness(t, remote, wf) | |||
| 187 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n a:\n uses: acme/shared/.github/workflows/a.yml@main\n"}, "") | |||
| 188 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 189 | + | first := r.await() | |||
| 190 | + | assert.Contains(t, first.GetContexts(), `"workflow_sha":"`+shaMain+`"`) | |||
| 191 | + | r.runJob(first, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil) | |||
| 192 | + | c.waitRunDone(run.GetId()) | |||
| 193 | + | ||||
| 194 | + | remote.set("acme/shared", ptrRef("refs/heads/main", shaOther)) | |||
| 195 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId()})) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | again := r.await() | |||
| 198 | + | assert.Contains(t, again.GetContexts(), `"workflow_sha":"`+shaOther+`"`, "the reference is resolved again") | |||
| 199 | + | r.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 200 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion()) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // A matrix of calls with max-parallel and both concurrency groups progresses | |||
| 204 | + | // one call at a time to the end, and the groups are free for the next run. | |||
| 205 | + | func TestCIReusableMaxParallelWithBothGroups(t *testing.T) { | |||
| 206 | + | c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 207 | + | caller := "on: push\njobs:\n c:\n strategy:\n max-parallel: 1\n matrix:\n v: [a, b, c]\n concurrency: caller-group\n uses: ./.github/workflows/callee.yml\n" | |||
| 208 | + | callee := strings.Replace(ghCallee, "jobs:\n", "concurrency: callee-group\njobs:\n", 1) | |||
| 209 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "") | |||
| 210 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 211 | + | for range 3 { | |||
| 212 | + | j := awaitRecord(c, r, run.GetId(), "c/test") | |||
| 213 | + | c.forge.CI.ReconcileConcurrency(c.ctx) | |||
| 214 | + | second, err := r.fetch("") | |||
| 215 | + | require.NoError(t, err) | |||
| 216 | + | assert.Nil(t, second, "one call at a time") | |||
| 217 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 218 | + | } | |||
| 219 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion()) | |||
| 220 | + | c.push("main", map[string]string{"x": "second"}, "") | |||
| 221 | + | next := c.awaitRunOf(".github/workflows/ci.yml", run.GetId()) | |||
| 222 | + | for range 3 { | |||
| 223 | + | j := awaitRecord(c, r, next.GetId(), "c/test") | |||
| 224 | + | r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 225 | + | } | |||
| 226 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(next.GetId()).GetConclusion()) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // The job-record budget holds again after a re-run of the failed jobs. | |||
| 230 | + | func TestCIReusableBudgetHoldsOnRerun(t *testing.T) { | |||
| 231 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 232 | + | vals := func(n int) string { | |||
| 233 | + | v := make([]string, n) | |||
| 234 | + | for i := range v { | |||
| 235 | + | v[i] = fmt.Sprint(i) | |||
| 236 | + | } | |||
| 237 | + | return "[" + strings.Join(v, ",") + "]" | |||
| 238 | + | } | |||
| 239 | + | caller := "on: push\njobs:\n c:\n strategy:\n matrix:\n v: " + vals(33) + "\n uses: ./.github/workflows/callee.yml\n" | |||
| 240 | + | callee := "on: workflow_call\njobs:\n j:\n strategy:\n matrix:\n v: " + vals(32) + "\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |||
| 241 | + | c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "") | |||
| 242 | + | run := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 243 | + | refused := func() bool { | |||
| 244 | + | for _, j := range c.getRun(run.GetId()).GetJobs() { | |||
| 245 | + | if strings.Contains(j.GetError(), "Workflows may not contain more than 1000 jobs across all referenced files") { | |||
| 246 | + | return true | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | return false | |||
| 250 | + | } | |||
| 251 | + | c.waitFor("the budget refusal", refused) | |||
| 252 | + | assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), 1000) | |||
| 253 | + | _, _ = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()})) | |||
| 254 | + | c.waitRunDone(run.GetId()) | |||
| 255 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId(), FailedOnly: true})) | |||
| 256 | + | require.NoError(t, err) | |||
| 257 | + | c.waitFor("the refusal again", refused) | |||
| 258 | + | assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), 1000) | |||
| 259 | + | } | |||
A internal/app/ci_reusable_test.go +697 -0
| @@ -0,0 +1,697 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | + | "bnema.dev/ermite/internal/usecase" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // fakeWorkflows stands in for github.com's file download: no test reaches the | |
| 25 | + | // network. It answers from a table of "owner/repo@sha:path" to file content. | |
| 26 | + | type fakeWorkflows struct { | |
| 27 | + | m *mocks.WorkflowFetcher | |
| 28 | + | mu sync.Mutex | |
| 29 | + | files map[string]string | |
| 30 | + | calls []string | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func newFakeWorkflows(t *testing.T) *fakeWorkflows { | |
| 34 | + | t.Helper() | |
| 35 | + | f := &fakeWorkflows{m: mocks.NewWorkflowFetcher(t), files: map[string]string{}} | |
| 36 | + | f.m.EXPECT().FetchWorkflow(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 37 | + | func(_ context.Context, owner, repo, sha, path string, _ int64) ([]byte, error) { | |
| 38 | + | f.mu.Lock() | |
| 39 | + | defer f.mu.Unlock() | |
| 40 | + | key := owner + "/" + repo + "@" + sha + ":" + path | |
| 41 | + | f.calls = append(f.calls, key) | |
| 42 | + | if s, ok := f.files[key]; ok { | |
| 43 | + | return []byte(s), nil | |
| 44 | + | } | |
| 45 | + | return nil, domain.ErrNotFound | |
| 46 | + | }).Maybe() | |
| 47 | + | return f | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (f *fakeWorkflows) set(key, content string) { | |
| 51 | + | f.mu.Lock() | |
| 52 | + | defer f.mu.Unlock() | |
| 53 | + | f.files[key] = content | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func newReusableHarness(t *testing.T, remote *fakeRemote, wf *fakeWorkflows) (*ciHarness, *fakeRunner) { | |
| 57 | + | t.Helper() | |
| 58 | + | h := newHarnessWith(t, harnessOpts{env: map[string]string{"ERMITE_SECRETS_KEY": secretsKeyA}}, func(o *options) { | |
| 59 | + | o.remoteLister, o.workflowFetcher = remote.m, wf.m | |
| 60 | + | o.ci = func(cfg *usecase.CIConfig) { cfg.Tick, cfg.PollTimeout = 20*time.Millisecond, 100*time.Millisecond } | |
| 61 | + | }) | |
| 62 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |
| 63 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | c.wt = h.workTree("main") | |
| 66 | + | return c, c.registerRunner("alpha", "ubuntu-latest") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func jobByKey(c *ciHarness, runID int64, key string) *pb.RunJob { | |
| 70 | + | for _, j := range c.getRun(runID).GetJobs() { | |
| 71 | + | if j.GetKey() == key { | |
| 72 | + | return j | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // awaitRecord fetches jobs until the one of the record with the key comes. The | |
| 79 | + | // runner is given the id of the job in its own workflow as the job key, so the | |
| 80 | + | // record is told apart by its job id. | |
| 81 | + | func awaitRecord(c *ciHarness, r *fakeRunner, runID int64, key string) *pb.Job { | |
| 82 | + | c.t.Helper() | |
| 83 | + | for range 20 { | |
| 84 | + | j := r.await() | |
| 85 | + | for _, rec := range c.getRun(runID).GetJobs() { | |
| 86 | + | if rec.GetKey() == key && rec.GetId() == j.GetJobId() { | |
| 87 | + | return j | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | c.t.Fatalf("no job %s of run %d", key, runID) | |
| 92 | + | return nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (c *ciHarness) waitRunDone(runID int64) *pb.Run { | |
| 96 | + | c.t.Helper() | |
| 97 | + | c.waitFor("the run completes", func() bool { | |
| 98 | + | return c.getRun(runID).GetRun().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |
| 99 | + | }) | |
| 100 | + | return c.getRun(runID).GetRun() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | const localCaller = `name: caller | |
| 104 | + | on: push | |
| 105 | + | jobs: | |
| 106 | + | prep: | |
| 107 | + | runs-on: ubuntu-latest | |
| 108 | + | outputs: | |
| 109 | + | who: ${{ steps.s.outputs.who }} | |
| 110 | + | steps: | |
| 111 | + | - id: s | |
| 112 | + | run: echo who=world >> $GITHUB_OUTPUT | |
| 113 | + | call: | |
| 114 | + | needs: prep | |
| 115 | + | uses: ./.github/workflows/callee.yml | |
| 116 | + | with: | |
| 117 | + | name: ${{ needs.prep.outputs.who }} | |
| 118 | + | count: 3 | |
| 119 | + | secrets: inherit | |
| 120 | + | after: | |
| 121 | + | needs: call | |
| 122 | + | runs-on: ubuntu-latest | |
| 123 | + | steps: | |
| 124 | + | - run: echo ${{ needs.call.outputs.greeting }} ${{ needs.call.outputs.total }} | |
| 125 | + | ` | |
| 126 | + | ||
| 127 | + | const localCallee = `name: callee | |
| 128 | + | on: | |
| 129 | + | workflow_call: | |
| 130 | + | inputs: | |
| 131 | + | name: | |
| 132 | + | type: string | |
| 133 | + | required: true | |
| 134 | + | count: | |
| 135 | + | type: number | |
| 136 | + | default: 1 | |
| 137 | + | loud: | |
| 138 | + | type: boolean | |
| 139 | + | default: false | |
| 140 | + | outputs: | |
| 141 | + | greeting: | |
| 142 | + | value: ${{ jobs.hello.outputs.msg }} | |
| 143 | + | total: | |
| 144 | + | value: ${{ inputs.count }} | |
| 145 | + | jobs: | |
| 146 | + | hello: | |
| 147 | + | runs-on: ubuntu-latest | |
| 148 | + | outputs: | |
| 149 | + | msg: ${{ steps.m.outputs.msg }} | |
| 150 | + | steps: | |
| 151 | + | - id: m | |
| 152 | + | run: echo "msg=hello ${{ inputs.name }} ${{ secrets.TOKEN }} ${{ inputs.loud }}" >> $GITHUB_OUTPUT | |
| 153 | + | second: | |
| 154 | + | needs: hello | |
| 155 | + | runs-on: ubuntu-latest | |
| 156 | + | steps: | |
| 157 | + | - run: echo ${{ needs.hello.outputs.msg }} | |
| 158 | + | ` | |
| 159 | + | ||
| 160 | + | // AC6: a local callee with inputs, secrets: inherit and outputs consumed by a | |
| 161 | + | // later job of the caller; the callee's jobs are keyed and named after the call. | |
| 162 | + | func TestCIReusableLocalCalleeWithInputsInheritAndOutputs(t *testing.T) { | |
| 163 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 164 | + | c.setSecret(repoScope("proj"), "TOKEN", "s3cr3t") | |
| 165 | + | c.setSecret(repoScope("proj"), "OTHER", "not-sent") | |
| 166 | + | c.push("main", map[string]string{".github/workflows/caller.yml": localCaller, ".github/workflows/callee.yml": localCallee}, "") | |
| 167 | + | ||
| 168 | + | var run *pb.Run | |
| 169 | + | c.waitFor("the run exists", func() bool { | |
| 170 | + | for _, r := range c.runs() { | |
| 171 | + | if r.GetWorkflowPath() == ".github/workflows/caller.yml" { | |
| 172 | + | run = r | |
| 173 | + | return true | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | return false | |
| 177 | + | }) | |
| 178 | + | prep := awaitRecord(c, runner, run.GetId(), "prep") | |
| 179 | + | runner.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"who": "world"}) | |
| 180 | + | ||
| 181 | + | hello := awaitRecord(c, runner, run.GetId(), "call/hello") | |
| 182 | + | // The runner is given the called file and the callee's id of the job. | |
| 183 | + | assert.Equal(t, ".github/workflows/callee.yml", hello.GetWorkflowPath()) | |
| 184 | + | assert.Contains(t, hello.GetWorkflowYaml(), "name: callee") | |
| 185 | + | assert.Equal(t, "s3cr3t", hello.GetSecrets()["TOKEN"], "inherit passes the caller's secrets") | |
| 186 | + | assert.NotContains(t, hello.GetSecrets(), "OTHER", "only the secrets the callee reads") | |
| 187 | + | assert.Contains(t, hello.GetContexts(), `"inputs":{"name":"world","count":3,"loud":false}`) | |
| 188 | + | assert.Contains(t, hello.GetContexts(), `"workflow_file_path":".github/workflows/callee.yml"`) | |
| 189 | + | assert.Equal(t, "call/hello", jobByKey(c, run.GetId(), "call/hello").GetKey()) | |
| 190 | + | assert.Equal(t, "call / hello", jobByKey(c, run.GetId(), "call/hello").GetName()) | |
| 191 | + | assert.Equal(t, []string{"call/hello"}, jobByKey(c, run.GetId(), "call/second").GetNeeds()) | |
| 192 | + | runner.runJob(hello, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"msg": "hello world s3cr3t false"}) | |
| 193 | + | ||
| 194 | + | second := awaitRecord(c, runner, run.GetId(), "call/second") | |
| 195 | + | assert.Contains(t, second.GetContexts(), `"msg":"hello world s3cr3t false"`) | |
| 196 | + | runner.runJob(second, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 197 | + | ||
| 198 | + | after := awaitRecord(c, runner, run.GetId(), "after") | |
| 199 | + | assert.Contains(t, after.GetContexts(), `"greeting":"hello world s3cr3t false"`, "outputs mapped from the callee's jobs") | |
| 200 | + | assert.Contains(t, after.GetContexts(), `"total":"3"`) | |
| 201 | + | runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 202 | + | ||
| 203 | + | done := c.waitRunDone(run.GetId()) | |
| 204 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, done.GetConclusion()) | |
| 205 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, jobByKey(c, run.GetId(), "call").GetConclusion()) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | const secretsCallee = `on: | |
| 209 | + | workflow_call: | |
| 210 | + | secrets: | |
| 211 | + | TOKEN: | |
| 212 | + | required: true | |
| 213 | + | OPTIONAL: | |
| 214 | + | jobs: | |
| 215 | + | j: | |
| 216 | + | runs-on: ubuntu-latest | |
| 217 | + | steps: | |
| 218 | + | - run: echo ${{ secrets.TOKEN }} ${{ secrets.OPTIONAL }} | |
| 219 | + | ` | |
| 220 | + | ||
| 221 | + | // D2: secrets are passed only when listed or inherited, renamed by the mapping. | |
| 222 | + | func TestCIReusableSecretsAreNotPassedUnlessExplicitOrInherited(t *testing.T) { | |
| 223 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 224 | + | c.setSecret(repoScope("proj"), "MY_TOKEN", "mapped-value") | |
| 225 | + | c.setSecret(repoScope("proj"), "OPTIONAL", "caller-optional") | |
| 226 | + | files := map[string]string{".github/workflows/callee.yml": secretsCallee} | |
| 227 | + | caller := func(secrets string) string { | |
| 228 | + | return "on: push\njobs:\n c:\n uses: ./.github/workflows/callee.yml\n" + secrets | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // Explicit: TOKEN from MY_TOKEN; OPTIONAL is not passed although the caller has one. | |
| 232 | + | files[".github/workflows/explicit.yml"] = caller(" secrets:\n TOKEN: ${{ secrets.MY_TOKEN }}\n") | |
| 233 | + | // Nothing: the callee requires TOKEN, so the call is refused. | |
| 234 | + | files[".github/workflows/none.yml"] = caller("") | |
| 235 | + | c.push("main", files, "") | |
| 236 | + | var explicit, none *pb.Run | |
| 237 | + | c.waitFor("two runs", func() bool { | |
| 238 | + | for _, r := range c.runs() { | |
| 239 | + | switch r.GetWorkflowPath() { | |
| 240 | + | case ".github/workflows/explicit.yml": | |
| 241 | + | explicit = r | |
| 242 | + | case ".github/workflows/none.yml": | |
| 243 | + | none = r | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | return explicit != nil && none != nil | |
| 247 | + | }) | |
| 248 | + | j := awaitRecord(c, runner, explicit.GetId(), "c/j") | |
| 249 | + | assert.Equal(t, map[string]string{"TOKEN": "mapped-value"}, j.GetSecrets()) | |
| 250 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 251 | + | ||
| 252 | + | done := c.waitRunDone(none.GetId()) | |
| 253 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, done.GetConclusion()) | |
| 254 | + | assert.Contains(t, jobByKey(c, none.GetId(), "c").GetError(), "Secret TOKEN is required, but not provided while calling.") | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // The callee must declare workflow_call; with: is checked against its inputs. | |
| 258 | + | func TestCIReusableCallErrors(t *testing.T) { | |
| 259 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 260 | + | callee := "on:\n workflow_call:\n inputs:\n n:\n type: number\n required: true\n flag:\n type: boolean\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |
| 261 | + | caller := func(with string) string { | |
| 262 | + | return "on: push\njobs:\n c:\n uses: ./.github/workflows/callee.yml\n" + with | |
| 263 | + | } | |
| 264 | + | files := map[string]string{ | |
| 265 | + | ".github/workflows/callee.yml": callee, | |
| 266 | + | ".github/workflows/nocall.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n", | |
| 267 | + | ".github/workflows/missing.yml": "on: push\njobs:\n c:\n uses: ./.github/workflows/nocall.yml\n", | |
| 268 | + | ".github/workflows/noreq.yml": caller(" with:\n flag: true\n"), | |
| 269 | + | ".github/workflows/badtype.yml": caller(" with:\n n: abc\n"), | |
| 270 | + | ".github/workflows/unknown.yml": caller(" with:\n n: 1\n zzz: 2\n"), | |
| 271 | + | ".github/workflows/gone.yml": "on: push\njobs:\n c:\n uses: ./.github/workflows/nothere.yml\n", | |
| 272 | + | ".github/workflows/ver.yml": "on: push\njobs:\n c:\n uses: ./.github/workflows/callee.yml@main\n", | |
| 273 | + | } | |
| 274 | + | c.push("main", files, "") | |
| 275 | + | want := map[string]string{ | |
| 276 | + | ".github/workflows/missing.yml": "workflow_call key is not defined in the referenced workflow.", | |
| 277 | + | ".github/workflows/noreq.yml": "Input n is required, but not provided while calling.", | |
| 278 | + | ".github/workflows/badtype.yml": "Unexpected value 'abc'", | |
| 279 | + | ".github/workflows/unknown.yml": "Invalid input, zzz is not defined in the referenced workflow.", | |
| 280 | + | ".github/workflows/gone.yml": "Unable to find reusable workflow", | |
| 281 | + | ".github/workflows/ver.yml": "cannot specify version when calling local workflows", | |
| 282 | + | } | |
| 283 | + | runs := map[string]*pb.Run{} | |
| 284 | + | c.waitFor("the runs exist", func() bool { | |
| 285 | + | for _, r := range c.runs() { | |
| 286 | + | runs[r.GetWorkflowPath()] = r | |
| 287 | + | } | |
| 288 | + | for p := range want { | |
| 289 | + | if runs[p] == nil { | |
| 290 | + | return false | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | return true | |
| 294 | + | }) | |
| 295 | + | for path, text := range want { | |
| 296 | + | c.waitRunDone(runs[path].GetId()) | |
| 297 | + | j := jobByKey(c, runs[path].GetId(), "c") | |
| 298 | + | require.NotNil(t, j, path) | |
| 299 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion(), path) | |
| 300 | + | assert.Contains(t, j.GetError(), text, path) | |
| 301 | + | } | |
| 302 | + | assert.Contains(t, jobByKey(c, runs[".github/workflows/noreq.yml"].GetId(), "c").GetError(), "In .github/workflows/noreq.yml (Line: 4, Col: 11): Error from called workflow") | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // AC6: depth 5 fails with the documented error; depth 4 runs. | |
| 306 | + | func TestCIReusableDepth(t *testing.T) { | |
| 307 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 308 | + | files := map[string]string{} | |
| 309 | + | for i := 1; i <= 5; i++ { | |
| 310 | + | body := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo leaf\n" | |
| 311 | + | if i < 5 { | |
| 312 | + | body = "on:\n workflow_call:\njobs:\n next:\n uses: ./.github/workflows/l" + string(rune('0'+i+1)) + ".yml\n" | |
| 313 | + | } | |
| 314 | + | files[".github/workflows/l"+string(rune('0'+i))+".yml"] = body | |
| 315 | + | } | |
| 316 | + | files[".github/workflows/top4.yml"] = "on: push\njobs:\n a:\n uses: ./.github/workflows/l2.yml\n" | |
| 317 | + | files[".github/workflows/top5.yml"] = "on: push\njobs:\n a:\n uses: ./.github/workflows/l1.yml\n" | |
| 318 | + | delete(files, ".github/workflows/l5.yml") | |
| 319 | + | files[".github/workflows/l5.yml"] = "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo leaf\n" | |
| 320 | + | c.push("main", files, "") | |
| 321 | + | var top4, top5 *pb.Run | |
| 322 | + | c.waitFor("both runs", func() bool { | |
| 323 | + | for _, r := range c.runs() { | |
| 324 | + | switch r.GetWorkflowPath() { | |
| 325 | + | case ".github/workflows/top4.yml": | |
| 326 | + | top4 = r | |
| 327 | + | case ".github/workflows/top5.yml": | |
| 328 | + | top5 = r | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | return top4 != nil && top5 != nil | |
| 332 | + | }) | |
| 333 | + | // top4: a(1) -> l2(2) -> l3(3) -> l4(4) -> l5 (leaf): the leaf runs at depth 4. | |
| 334 | + | leaf := awaitRecord(c, runner, top4.GetId(), "a/next/next/next/j") | |
| 335 | + | runner.runJob(leaf, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 336 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(top4.GetId()).GetConclusion()) | |
| 337 | + | ||
| 338 | + | // top5: one more level: l1 -> ... -> l5 holds a call at depth 5. | |
| 339 | + | c.waitRunDone(top5.GetId()) | |
| 340 | + | var failed *pb.RunJob | |
| 341 | + | for _, j := range c.getRun(top5.GetId()).GetJobs() { | |
| 342 | + | if j.GetConclusion() == pb.CiConclusion_CI_CONCLUSION_FAILURE && strings.HasSuffix(j.GetKey(), "/next") { | |
| 343 | + | failed = j | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | require.NotNil(t, failed, "the call that is too deep fails") | |
| 347 | + | assert.Contains(t, failed.GetError(), "Nested reusable workflow depth exceeded 4.") | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | const ghCallee = `on: | |
| 351 | + | workflow_call: | |
| 352 | + | inputs: | |
| 353 | + | go-version: | |
| 354 | + | type: string | |
| 355 | + | default: stable | |
| 356 | + | jobs: | |
| 357 | + | test: | |
| 358 | + | runs-on: ubuntu-latest | |
| 359 | + | steps: | |
| 360 | + | - run: echo ${{ inputs.go-version }} | |
| 361 | + | ` | |
| 362 | + | ||
| 363 | + | // AC6: a github.com callee, resolved with the CI-2 ref resolution and fetched | |
| 364 | + | // at the resolved commit through the fetcher port (a fake here). | |
| 365 | + | func TestCIReusableFromGitHub(t *testing.T) { | |
| 366 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |
| 367 | + | remote.set("acme/gh-actions", ptrRef("refs/heads/main", shaMain)) | |
| 368 | + | wf.set("acme/gh-actions@"+shaMain+":.github/workflows/go-ci.yml", ghCallee) | |
| 369 | + | c, runner := newReusableHarness(t, remote, wf) | |
| 370 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n ci:\n uses: acme/gh-actions/.github/workflows/go-ci.yml@main\n with:\n go-version: '1.27'\n"}, "") | |
| 371 | + | var r *pb.Run | |
| 372 | + | c.waitFor("the run", func() bool { | |
| 373 | + | if rs := c.runs(); len(rs) > 0 { | |
| 374 | + | r = rs[0] | |
| 375 | + | } | |
| 376 | + | return r != nil | |
| 377 | + | }) | |
| 378 | + | j := awaitRecord(c, runner, r.GetId(), "ci/test") | |
| 379 | + | assert.Contains(t, j.GetContexts(), `"inputs":{"go-version":"1.27"}`) | |
| 380 | + | assert.Contains(t, j.GetContexts(), `"workflow_ref":"acme/gh-actions/.github/workflows/go-ci.yml@main"`) | |
| 381 | + | assert.Contains(t, j.GetContexts(), `"workflow_sha":"`+shaMain+`"`) | |
| 382 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 383 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r.GetId()).GetConclusion()) | |
| 384 | + | assert.True(t, remote.asked("acme/gh-actions")) | |
| 385 | + | wf.mu.Lock() | |
| 386 | + | assert.Equal(t, []string{"acme/gh-actions@" + shaMain + ":.github/workflows/go-ci.yml"}, wf.calls) | |
| 387 | + | wf.mu.Unlock() | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | // AC6: an owner repository of the server wins over github.com. | |
| 391 | + | func TestCIReusableFromAnErmiteRepository(t *testing.T) { | |
| 392 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |
| 393 | + | remote.set("owner/shared", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite | |
| 394 | + | c, runner := newReusableHarness(t, remote, wf) | |
| 395 | + | c.seed("shared", domain.VisibilityPrivate) | |
| 396 | + | wt := c.workTree("main") | |
| 397 | + | c.writeFile(wt, ".github/workflows/lib.yml", []byte(ghCallee)) | |
| 398 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/shared.git"), "main") | |
| 399 | + | ||
| 400 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n lib:\n uses: owner/shared/.github/workflows/lib.yml@main\n"}, "") | |
| 401 | + | var r *pb.Run | |
| 402 | + | c.waitFor("the run", func() bool { | |
| 403 | + | if rs := c.runs(); len(rs) > 0 { | |
| 404 | + | r = rs[0] | |
| 405 | + | } | |
| 406 | + | return r != nil | |
| 407 | + | }) | |
| 408 | + | j := awaitRecord(c, runner, r.GetId(), "lib/test") | |
| 409 | + | assert.Contains(t, j.GetContexts(), `"inputs":{"go-version":"stable"}`, "the default") | |
| 410 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 411 | + | c.waitRunDone(r.GetId()) | |
| 412 | + | assert.False(t, remote.asked("owner/shared")) | |
| 413 | + | wf.mu.Lock() | |
| 414 | + | assert.Empty(t, wf.calls, "github.com is not asked for an ermite repository") | |
| 415 | + | wf.mu.Unlock() | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // A matrix on the calling job expands the callee once per leg. | |
| 419 | + | func TestCIReusableMatrixCaller(t *testing.T) { | |
| 420 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 421 | + | callee := "on:\n workflow_call:\n inputs:\n v:\n type: string\n required: true\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ inputs.v }}\n" | |
| 422 | + | caller := "on: push\njobs:\n c:\n strategy:\n matrix:\n v: [a, b]\n uses: ./.github/workflows/callee.yml\n with:\n v: ${{ matrix.v }}\n" | |
| 423 | + | c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": caller}, "") | |
| 424 | + | var r *pb.Run | |
| 425 | + | c.waitFor("the run", func() bool { | |
| 426 | + | for _, x := range c.runs() { | |
| 427 | + | if x.GetWorkflowPath() == ".github/workflows/ci.yml" { | |
| 428 | + | r = x | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | return r != nil | |
| 432 | + | }) | |
| 433 | + | got := map[string]bool{} | |
| 434 | + | for range 2 { | |
| 435 | + | j := awaitRecord(c, runner, r.GetId(), "c/j") | |
| 436 | + | switch { | |
| 437 | + | case strings.Contains(j.GetContexts(), `"inputs":{"v":"a"}`): | |
| 438 | + | got["a"] = true | |
| 439 | + | case strings.Contains(j.GetContexts(), `"inputs":{"v":"b"}`): | |
| 440 | + | got["b"] = true | |
| 441 | + | } | |
| 442 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 443 | + | } | |
| 444 | + | assert.Equal(t, map[string]bool{"a": true, "b": true}, got) | |
| 445 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r.GetId()).GetConclusion()) | |
| 446 | + | names := map[string]bool{} | |
| 447 | + | for _, j := range c.getRun(r.GetId()).GetJobs() { | |
| 448 | + | names[j.GetName()] = true | |
| 449 | + | } | |
| 450 | + | assert.True(t, names["c (a) / j"] && names["c (b) / j"], "%v", names) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | // D3: the callee can only lower the caller's permissions; a request above is | |
| 454 | + | // refused with the runner's text, and a lower one is kept. | |
| 455 | + | func TestCIReusablePermissionsOnlyLowered(t *testing.T) { | |
| 456 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 457 | + | callee := func(perm string) string { | |
| 458 | + | return "on:\n workflow_call:\n" + perm + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |
| 459 | + | } | |
| 460 | + | caller := func(perm, callee string) string { | |
| 461 | + | return "on: push\n" + perm + "jobs:\n c:\n uses: ./.github/workflows/" + callee + ".yml\n" | |
| 462 | + | } | |
| 463 | + | c.push("main", map[string]string{ | |
| 464 | + | ".github/workflows/lower.yml": callee("permissions:\n contents: read\n"), | |
| 465 | + | ".github/workflows/higher.yml": callee("permissions:\n contents: write\n"), | |
| 466 | + | ".github/workflows/plain.yml": callee(""), | |
| 467 | + | ".github/workflows/a.yml": caller("permissions:\n contents: write\n pull-requests: write\n", "lower"), | |
| 468 | + | ".github/workflows/b.yml": caller("permissions:\n contents: read\n", "higher"), | |
| 469 | + | ".github/workflows/p.yml": caller("permissions:\n contents: write\n", "plain"), | |
| 470 | + | }, "") | |
| 471 | + | runs := map[string]*pb.Run{} | |
| 472 | + | c.waitFor("three runs", func() bool { | |
| 473 | + | for _, r := range c.runs() { | |
| 474 | + | runs[r.GetWorkflowPath()] = r | |
| 475 | + | } | |
| 476 | + | return runs[".github/workflows/a.yml"] != nil && runs[".github/workflows/b.yml"] != nil && runs[".github/workflows/p.yml"] != nil | |
| 477 | + | }) | |
| 478 | + | lower := awaitRecord(c, runner, runs[".github/workflows/a.yml"].GetId(), "c/j") | |
| 479 | + | assert.Equal(t, "read", lower.GetPermissions().GetLevels()["contents"], "the callee lowers") | |
| 480 | + | assert.NotContains(t, lower.GetPermissions().GetLevels(), "pull-requests") | |
| 481 | + | runner.runJob(lower, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 482 | + | ||
| 483 | + | plain := awaitRecord(c, runner, runs[".github/workflows/p.yml"].GetId(), "c/j") | |
| 484 | + | assert.Equal(t, "write", plain.GetPermissions().GetLevels()["contents"], "a callee without permissions keeps the caller's") | |
| 485 | + | runner.runJob(plain, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 486 | + | ||
| 487 | + | c.waitRunDone(runs[".github/workflows/b.yml"].GetId()) | |
| 488 | + | var errText string | |
| 489 | + | for _, j := range c.getRun(runs[".github/workflows/b.yml"].GetId()).GetJobs() { | |
| 490 | + | if j.GetKey() == "c/j" { | |
| 491 | + | errText = j.GetError() | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | assert.Contains(t, errText, "is requesting 'contents: write', but is only allowed 'contents: read'.") | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | // D4: the job-level concurrency of a callee's job applies. | |
| 498 | + | func TestCIReusableCalleeJobConcurrency(t *testing.T) { | |
| 499 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 500 | + | callee := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n concurrency:\n group: callee-job-group\n steps:\n - run: echo\n" | |
| 501 | + | caller := "on: push\njobs:\n c:\n uses: ./.github/workflows/callee.yml\n" | |
| 502 | + | c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": caller}, "") | |
| 503 | + | r1 := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 504 | + | j1 := awaitRecord(c, runner, r1.GetId(), "c/j") | |
| 505 | + | assert.Equal(t, "callee-job-group", jobByKey(c, r1.GetId(), "c/j").GetConcurrencyGroup()) | |
| 506 | + | c.push("main", map[string]string{"x.txt": "1"}, "second") | |
| 507 | + | r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId()) | |
| 508 | + | c.waitFor("the second run's job is pending on the group", func() bool { | |
| 509 | + | j := jobByKey(c, r2.GetId(), "c/j") | |
| 510 | + | return j != nil && j.GetStatus() == pb.CiStatus_CI_STATUS_PENDING | |
| 511 | + | }) | |
| 512 | + | runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 513 | + | j2 := awaitRecord(c, runner, r2.GetId(), "c/j") | |
| 514 | + | runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 515 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion()) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // awaitRunOf waits for the newest run of a workflow file whose id is above after. | |
| 519 | + | func (c *ciHarness) awaitRunOf(path string, after int64) *pb.Run { | |
| 520 | + | c.t.Helper() | |
| 521 | + | var r *pb.Run | |
| 522 | + | c.waitFor("a run of "+path, func() bool { | |
| 523 | + | for _, x := range c.runs() { | |
| 524 | + | if x.GetWorkflowPath() == path && x.GetId() > after { | |
| 525 | + | r = x | |
| 526 | + | return true | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | return false | |
| 530 | + | }) | |
| 531 | + | return r | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | const concCallee = `on: | |
| 535 | + | workflow_call: | |
| 536 | + | inputs: | |
| 537 | + | env: | |
| 538 | + | type: string | |
| 539 | + | default: prod | |
| 540 | + | %s | |
| 541 | + | jobs: | |
| 542 | + | j: | |
| 543 | + | runs-on: ubuntu-latest | |
| 544 | + | steps: | |
| 545 | + | - run: echo | |
| 546 | + | ` | |
| 547 | + | ||
| 548 | + | func concCaller(top string) string { | |
| 549 | + | return "on: push\njobs:\n c:\n" + top + " uses: ./.github/workflows/callee.yml\n with:\n env: ${{ github.ref_name }}\n" | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | // D4: the concurrency of the calling job, and the workflow-level concurrency of | |
| 553 | + | // the called workflow (evaluated with the callee's inputs), hold the whole call: | |
| 554 | + | // its jobs are not assignable until the call has the group, and the group is | |
| 555 | + | // freed when the call ends. Both groups are tested in turn. | |
| 556 | + | func TestCIReusableCallConcurrency(t *testing.T) { | |
| 557 | + | for _, tt := range []struct{ name, caller, callee, group string }{ | |
| 558 | + | {"calling job", " concurrency:\n group: call-group\n", "", "call-group"}, | |
| 559 | + | {"called workflow", "", "concurrency: deploy-${{ inputs.env }}\n", "deploy-main"}, | |
| 560 | + | } { | |
| 561 | + | t.Run(tt.name, func(t *testing.T) { | |
| 562 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 563 | + | callee := strings.Replace(concCallee, "%s\n", tt.callee, 1) | |
| 564 | + | files := map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": concCaller(tt.caller)} | |
| 565 | + | c.push("main", files, "") | |
| 566 | + | r1 := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 567 | + | j1 := awaitRecord(c, runner, r1.GetId(), "c/j") | |
| 568 | + | ||
| 569 | + | c.push("main", map[string]string{"x.txt": "1"}, "second") | |
| 570 | + | r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId()) | |
| 571 | + | c.waitFor("the second call waits for the group", func() bool { | |
| 572 | + | call := jobByKey(c, r2.GetId(), "c") | |
| 573 | + | return call != nil && call.GetStatus() == pb.CiStatus_CI_STATUS_PENDING | |
| 574 | + | }) | |
| 575 | + | // Its jobs exist but are not assignable; they show the group. | |
| 576 | + | j, err := runner.fetch("") | |
| 577 | + | require.NoError(t, err) | |
| 578 | + | assert.Nil(t, j, "no job of the second call is given while the first holds the group") | |
| 579 | + | ||
| 580 | + | runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 581 | + | j2 := awaitRecord(c, runner, r2.GetId(), "c/j") | |
| 582 | + | runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 583 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion()) | |
| 584 | + | ||
| 585 | + | // Freed with the call: a third call takes it at once. | |
| 586 | + | c.push("main", map[string]string{"y.txt": "1"}, "third") | |
| 587 | + | r3 := c.awaitRunOf(".github/workflows/ci.yml", r2.GetId()) | |
| 588 | + | j3 := awaitRecord(c, runner, r3.GetId(), "c/j") | |
| 589 | + | runner.runJob(j3, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 590 | + | c.waitRunDone(r3.GetId()) | |
| 591 | + | }) | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | // D4: cancel-in-progress on a call cancels the running call: its running job is | |
| 596 | + | // asked to stop, the call ends cancelled with GitHub's annotation, and the | |
| 597 | + | // waiting call takes the group. | |
| 598 | + | func TestCIReusableCallCancelInProgress(t *testing.T) { | |
| 599 | + | for _, tt := range []struct{ name, caller, callee string }{ | |
| 600 | + | {"calling job", " concurrency:\n group: Cip\n cancel-in-progress: true\n", ""}, | |
| 601 | + | {"called workflow", "", "concurrency:\n group: Cip\n cancel-in-progress: true\n"}, | |
| 602 | + | } { | |
| 603 | + | t.Run(tt.name, func(t *testing.T) { | |
| 604 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |
| 605 | + | callee := strings.Replace(concCallee, "%s\n", tt.callee, 1) | |
| 606 | + | c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": concCaller(tt.caller)}, "") | |
| 607 | + | r1 := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 608 | + | j1 := awaitRecord(c, runner, r1.GetId(), "c/j") | |
| 609 | + | watch, err := runner.jobAPI(j1).runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: j1.GetJobId()})) | |
| 610 | + | require.NoError(t, err) | |
| 611 | + | ||
| 612 | + | c.push("main", map[string]string{"x.txt": "1"}, "second") | |
| 613 | + | r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId()) | |
| 614 | + | var cancel *pb.CancelRequest | |
| 615 | + | for watch.Receive() { | |
| 616 | + | if m := watch.Msg().GetCancel(); m != nil { | |
| 617 | + | cancel = m | |
| 618 | + | break | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | require.NotNil(t, cancel, "the running job of the call is asked to stop") | |
| 622 | + | assert.Contains(t, cancel.GetMessage(), "higher priority waiting request for 'Cip'") | |
| 623 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, c.cancelledCall(r1), "the call ends cancelled") | |
| 624 | + | ||
| 625 | + | _, err = runner.jobAPI(j1).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{ | |
| 626 | + | JobId: j1.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED, | |
| 627 | + | })) | |
| 628 | + | require.NoError(t, err) | |
| 629 | + | j2 := awaitRecord(c, runner, r2.GetId(), "c/j") | |
| 630 | + | runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 631 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion()) | |
| 632 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, c.waitRunDone(r1.GetId()).GetConclusion()) | |
| 633 | + | }) | |
| 634 | + | } | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // cancelledCall waits for the call record "c" of a run to end and returns its conclusion. | |
| 638 | + | func (c *ciHarness) cancelledCall(r *pb.Run) pb.CiConclusion { | |
| 639 | + | c.t.Helper() | |
| 640 | + | c.waitFor("the call ends", func() bool { | |
| 641 | + | j := jobByKey(c, r.GetId(), "c") | |
| 642 | + | return j != nil && j.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |
| 643 | + | }) | |
| 644 | + | return jobByKey(c, r.GetId(), "c").GetConclusion() | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | // A private repository of the server that the actor cannot read is not a | |
| 648 | + | // source of reusable workflows: the call fails as for a repository that does | |
| 649 | + | // not exist, and a reader gets it. | |
| 650 | + | func TestCIReusableFromAPrivateErmiteRepository(t *testing.T) { | |
| 651 | + | for _, tt := range []struct { | |
| 652 | + | name string | |
| 653 | + | token func(c *ciHarness) (string, string) | |
| 654 | + | reads bool | |
| 655 | + | }{ | |
| 656 | + | {"an owner token", nil, true}, | |
| 657 | + | {"a token that lists the caller only", func(c *ciHarness) (string, string) { | |
| 658 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 659 | + | }, false}, | |
| 660 | + | {"a token that lists both", func(c *ciHarness) (string, string) { | |
| 661 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "shared") | |
| 662 | + | }, true}, | |
| 663 | + | } { | |
| 664 | + | t.Run(tt.name, func(t *testing.T) { | |
| 665 | + | remote := newFakeRemote(t) | |
| 666 | + | remote.set("owner/shared", ptrRef("refs/heads/main", shaMain)) | |
| 667 | + | wf := newFakeWorkflows(t) | |
| 668 | + | c, runner := newReusableHarness(t, remote, wf) | |
| 669 | + | c.seed("shared", domain.VisibilityPrivate) | |
| 670 | + | wt := c.workTree("main") | |
| 671 | + | c.writeFile(wt, ".github/workflows/lib.yml", []byte(ghCallee)) | |
| 672 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/shared.git"), "main") | |
| 673 | + | secret := adminToken | |
| 674 | + | if tt.token != nil { | |
| 675 | + | secret, _ = tt.token(c) | |
| 676 | + | } | |
| 677 | + | c.pushAs(secret, "main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n lib:\n uses: owner/shared/.github/workflows/lib.yml@main\n"}) | |
| 678 | + | r := c.awaitRunOf(".github/workflows/ci.yml", 0) | |
| 679 | + | if tt.reads { | |
| 680 | + | j := awaitRecord(c, runner, r.GetId(), "lib/test") | |
| 681 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |
| 682 | + | c.waitRunDone(r.GetId()) | |
| 683 | + | return | |
| 684 | + | } | |
| 685 | + | c.waitRunDone(r.GetId()) | |
| 686 | + | j := jobByKey(c, r.GetId(), "lib") | |
| 687 | + | require.NotNil(t, j) | |
| 688 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | |
| 689 | + | assert.Equal(t, "Unable to resolve action `owner/shared@main`, repository not found", j.GetError(), | |
| 690 | + | "the text of a repository that is not there, nothing about the private one") | |
| 691 | + | assert.False(t, remote.asked("owner/shared"), "never falls through to github.com") | |
| 692 | + | wf.mu.Lock() | |
| 693 | + | assert.Empty(t, wf.calls) | |
| 694 | + | wf.mu.Unlock() | |
| 695 | + | }) | |
| 696 | + | } | |
| 697 | + | } | |
| @@ -0,0 +1,697 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "connectrpc.com/connect" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 21 | + | "bnema.dev/ermite/internal/usecase" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // fakeWorkflows stands in for github.com's file download: no test reaches the | |||
| 25 | + | // network. It answers from a table of "owner/repo@sha:path" to file content. | |||
| 26 | + | type fakeWorkflows struct { | |||
| 27 | + | m *mocks.WorkflowFetcher | |||
| 28 | + | mu sync.Mutex | |||
| 29 | + | files map[string]string | |||
| 30 | + | calls []string | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func newFakeWorkflows(t *testing.T) *fakeWorkflows { | |||
| 34 | + | t.Helper() | |||
| 35 | + | f := &fakeWorkflows{m: mocks.NewWorkflowFetcher(t), files: map[string]string{}} | |||
| 36 | + | f.m.EXPECT().FetchWorkflow(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 37 | + | func(_ context.Context, owner, repo, sha, path string, _ int64) ([]byte, error) { | |||
| 38 | + | f.mu.Lock() | |||
| 39 | + | defer f.mu.Unlock() | |||
| 40 | + | key := owner + "/" + repo + "@" + sha + ":" + path | |||
| 41 | + | f.calls = append(f.calls, key) | |||
| 42 | + | if s, ok := f.files[key]; ok { | |||
| 43 | + | return []byte(s), nil | |||
| 44 | + | } | |||
| 45 | + | return nil, domain.ErrNotFound | |||
| 46 | + | }).Maybe() | |||
| 47 | + | return f | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (f *fakeWorkflows) set(key, content string) { | |||
| 51 | + | f.mu.Lock() | |||
| 52 | + | defer f.mu.Unlock() | |||
| 53 | + | f.files[key] = content | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func newReusableHarness(t *testing.T, remote *fakeRemote, wf *fakeWorkflows) (*ciHarness, *fakeRunner) { | |||
| 57 | + | t.Helper() | |||
| 58 | + | h := newHarnessWith(t, harnessOpts{env: map[string]string{"ERMITE_SECRETS_KEY": secretsKeyA}}, func(o *options) { | |||
| 59 | + | o.remoteLister, o.workflowFetcher = remote.m, wf.m | |||
| 60 | + | o.ci = func(cfg *usecase.CIConfig) { cfg.Tick, cfg.PollTimeout = 20*time.Millisecond, 100*time.Millisecond } | |||
| 61 | + | }) | |||
| 62 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |||
| 63 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | c.wt = h.workTree("main") | |||
| 66 | + | return c, c.registerRunner("alpha", "ubuntu-latest") | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func jobByKey(c *ciHarness, runID int64, key string) *pb.RunJob { | |||
| 70 | + | for _, j := range c.getRun(runID).GetJobs() { | |||
| 71 | + | if j.GetKey() == key { | |||
| 72 | + | return j | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // awaitRecord fetches jobs until the one of the record with the key comes. The | |||
| 79 | + | // runner is given the id of the job in its own workflow as the job key, so the | |||
| 80 | + | // record is told apart by its job id. | |||
| 81 | + | func awaitRecord(c *ciHarness, r *fakeRunner, runID int64, key string) *pb.Job { | |||
| 82 | + | c.t.Helper() | |||
| 83 | + | for range 20 { | |||
| 84 | + | j := r.await() | |||
| 85 | + | for _, rec := range c.getRun(runID).GetJobs() { | |||
| 86 | + | if rec.GetKey() == key && rec.GetId() == j.GetJobId() { | |||
| 87 | + | return j | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | c.t.Fatalf("no job %s of run %d", key, runID) | |||
| 92 | + | return nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (c *ciHarness) waitRunDone(runID int64) *pb.Run { | |||
| 96 | + | c.t.Helper() | |||
| 97 | + | c.waitFor("the run completes", func() bool { | |||
| 98 | + | return c.getRun(runID).GetRun().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |||
| 99 | + | }) | |||
| 100 | + | return c.getRun(runID).GetRun() | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | const localCaller = `name: caller | |||
| 104 | + | on: push | |||
| 105 | + | jobs: | |||
| 106 | + | prep: | |||
| 107 | + | runs-on: ubuntu-latest | |||
| 108 | + | outputs: | |||
| 109 | + | who: ${{ steps.s.outputs.who }} | |||
| 110 | + | steps: | |||
| 111 | + | - id: s | |||
| 112 | + | run: echo who=world >> $GITHUB_OUTPUT | |||
| 113 | + | call: | |||
| 114 | + | needs: prep | |||
| 115 | + | uses: ./.github/workflows/callee.yml | |||
| 116 | + | with: | |||
| 117 | + | name: ${{ needs.prep.outputs.who }} | |||
| 118 | + | count: 3 | |||
| 119 | + | secrets: inherit | |||
| 120 | + | after: | |||
| 121 | + | needs: call | |||
| 122 | + | runs-on: ubuntu-latest | |||
| 123 | + | steps: | |||
| 124 | + | - run: echo ${{ needs.call.outputs.greeting }} ${{ needs.call.outputs.total }} | |||
| 125 | + | ` | |||
| 126 | + | ||||
| 127 | + | const localCallee = `name: callee | |||
| 128 | + | on: | |||
| 129 | + | workflow_call: | |||
| 130 | + | inputs: | |||
| 131 | + | name: | |||
| 132 | + | type: string | |||
| 133 | + | required: true | |||
| 134 | + | count: | |||
| 135 | + | type: number | |||
| 136 | + | default: 1 | |||
| 137 | + | loud: | |||
| 138 | + | type: boolean | |||
| 139 | + | default: false | |||
| 140 | + | outputs: | |||
| 141 | + | greeting: | |||
| 142 | + | value: ${{ jobs.hello.outputs.msg }} | |||
| 143 | + | total: | |||
| 144 | + | value: ${{ inputs.count }} | |||
| 145 | + | jobs: | |||
| 146 | + | hello: | |||
| 147 | + | runs-on: ubuntu-latest | |||
| 148 | + | outputs: | |||
| 149 | + | msg: ${{ steps.m.outputs.msg }} | |||
| 150 | + | steps: | |||
| 151 | + | - id: m | |||
| 152 | + | run: echo "msg=hello ${{ inputs.name }} ${{ secrets.TOKEN }} ${{ inputs.loud }}" >> $GITHUB_OUTPUT | |||
| 153 | + | second: | |||
| 154 | + | needs: hello | |||
| 155 | + | runs-on: ubuntu-latest | |||
| 156 | + | steps: | |||
| 157 | + | - run: echo ${{ needs.hello.outputs.msg }} | |||
| 158 | + | ` | |||
| 159 | + | ||||
| 160 | + | // AC6: a local callee with inputs, secrets: inherit and outputs consumed by a | |||
| 161 | + | // later job of the caller; the callee's jobs are keyed and named after the call. | |||
| 162 | + | func TestCIReusableLocalCalleeWithInputsInheritAndOutputs(t *testing.T) { | |||
| 163 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 164 | + | c.setSecret(repoScope("proj"), "TOKEN", "s3cr3t") | |||
| 165 | + | c.setSecret(repoScope("proj"), "OTHER", "not-sent") | |||
| 166 | + | c.push("main", map[string]string{".github/workflows/caller.yml": localCaller, ".github/workflows/callee.yml": localCallee}, "") | |||
| 167 | + | ||||
| 168 | + | var run *pb.Run | |||
| 169 | + | c.waitFor("the run exists", func() bool { | |||
| 170 | + | for _, r := range c.runs() { | |||
| 171 | + | if r.GetWorkflowPath() == ".github/workflows/caller.yml" { | |||
| 172 | + | run = r | |||
| 173 | + | return true | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | return false | |||
| 177 | + | }) | |||
| 178 | + | prep := awaitRecord(c, runner, run.GetId(), "prep") | |||
| 179 | + | runner.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"who": "world"}) | |||
| 180 | + | ||||
| 181 | + | hello := awaitRecord(c, runner, run.GetId(), "call/hello") | |||
| 182 | + | // The runner is given the called file and the callee's id of the job. | |||
| 183 | + | assert.Equal(t, ".github/workflows/callee.yml", hello.GetWorkflowPath()) | |||
| 184 | + | assert.Contains(t, hello.GetWorkflowYaml(), "name: callee") | |||
| 185 | + | assert.Equal(t, "s3cr3t", hello.GetSecrets()["TOKEN"], "inherit passes the caller's secrets") | |||
| 186 | + | assert.NotContains(t, hello.GetSecrets(), "OTHER", "only the secrets the callee reads") | |||
| 187 | + | assert.Contains(t, hello.GetContexts(), `"inputs":{"name":"world","count":3,"loud":false}`) | |||
| 188 | + | assert.Contains(t, hello.GetContexts(), `"workflow_file_path":".github/workflows/callee.yml"`) | |||
| 189 | + | assert.Equal(t, "call/hello", jobByKey(c, run.GetId(), "call/hello").GetKey()) | |||
| 190 | + | assert.Equal(t, "call / hello", jobByKey(c, run.GetId(), "call/hello").GetName()) | |||
| 191 | + | assert.Equal(t, []string{"call/hello"}, jobByKey(c, run.GetId(), "call/second").GetNeeds()) | |||
| 192 | + | runner.runJob(hello, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"msg": "hello world s3cr3t false"}) | |||
| 193 | + | ||||
| 194 | + | second := awaitRecord(c, runner, run.GetId(), "call/second") | |||
| 195 | + | assert.Contains(t, second.GetContexts(), `"msg":"hello world s3cr3t false"`) | |||
| 196 | + | runner.runJob(second, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 197 | + | ||||
| 198 | + | after := awaitRecord(c, runner, run.GetId(), "after") | |||
| 199 | + | assert.Contains(t, after.GetContexts(), `"greeting":"hello world s3cr3t false"`, "outputs mapped from the callee's jobs") | |||
| 200 | + | assert.Contains(t, after.GetContexts(), `"total":"3"`) | |||
| 201 | + | runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 202 | + | ||||
| 203 | + | done := c.waitRunDone(run.GetId()) | |||
| 204 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, done.GetConclusion()) | |||
| 205 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, jobByKey(c, run.GetId(), "call").GetConclusion()) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | const secretsCallee = `on: | |||
| 209 | + | workflow_call: | |||
| 210 | + | secrets: | |||
| 211 | + | TOKEN: | |||
| 212 | + | required: true | |||
| 213 | + | OPTIONAL: | |||
| 214 | + | jobs: | |||
| 215 | + | j: | |||
| 216 | + | runs-on: ubuntu-latest | |||
| 217 | + | steps: | |||
| 218 | + | - run: echo ${{ secrets.TOKEN }} ${{ secrets.OPTIONAL }} | |||
| 219 | + | ` | |||
| 220 | + | ||||
| 221 | + | // D2: secrets are passed only when listed or inherited, renamed by the mapping. | |||
| 222 | + | func TestCIReusableSecretsAreNotPassedUnlessExplicitOrInherited(t *testing.T) { | |||
| 223 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 224 | + | c.setSecret(repoScope("proj"), "MY_TOKEN", "mapped-value") | |||
| 225 | + | c.setSecret(repoScope("proj"), "OPTIONAL", "caller-optional") | |||
| 226 | + | files := map[string]string{".github/workflows/callee.yml": secretsCallee} | |||
| 227 | + | caller := func(secrets string) string { | |||
| 228 | + | return "on: push\njobs:\n c:\n uses: ./.github/workflows/callee.yml\n" + secrets | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // Explicit: TOKEN from MY_TOKEN; OPTIONAL is not passed although the caller has one. | |||
| 232 | + | files[".github/workflows/explicit.yml"] = caller(" secrets:\n TOKEN: ${{ secrets.MY_TOKEN }}\n") | |||
| 233 | + | // Nothing: the callee requires TOKEN, so the call is refused. | |||
| 234 | + | files[".github/workflows/none.yml"] = caller("") | |||
| 235 | + | c.push("main", files, "") | |||
| 236 | + | var explicit, none *pb.Run | |||
| 237 | + | c.waitFor("two runs", func() bool { | |||
| 238 | + | for _, r := range c.runs() { | |||
| 239 | + | switch r.GetWorkflowPath() { | |||
| 240 | + | case ".github/workflows/explicit.yml": | |||
| 241 | + | explicit = r | |||
| 242 | + | case ".github/workflows/none.yml": | |||
| 243 | + | none = r | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | return explicit != nil && none != nil | |||
| 247 | + | }) | |||
| 248 | + | j := awaitRecord(c, runner, explicit.GetId(), "c/j") | |||
| 249 | + | assert.Equal(t, map[string]string{"TOKEN": "mapped-value"}, j.GetSecrets()) | |||
| 250 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 251 | + | ||||
| 252 | + | done := c.waitRunDone(none.GetId()) | |||
| 253 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, done.GetConclusion()) | |||
| 254 | + | assert.Contains(t, jobByKey(c, none.GetId(), "c").GetError(), "Secret TOKEN is required, but not provided while calling.") | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // The callee must declare workflow_call; with: is checked against its inputs. | |||
| 258 | + | func TestCIReusableCallErrors(t *testing.T) { | |||
| 259 | + | c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 260 | + | callee := "on:\n workflow_call:\n inputs:\n n:\n type: number\n required: true\n flag:\n type: boolean\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |||
| 261 | + | caller := func(with string) string { | |||
| 262 | + | return "on: push\njobs:\n c:\n uses: ./.github/workflows/callee.yml\n" + with | |||
| 263 | + | } | |||
| 264 | + | files := map[string]string{ | |||
| 265 | + | ".github/workflows/callee.yml": callee, | |||
| 266 | + | ".github/workflows/nocall.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n", | |||
| 267 | + | ".github/workflows/missing.yml": "on: push\njobs:\n c:\n uses: ./.github/workflows/nocall.yml\n", | |||
| 268 | + | ".github/workflows/noreq.yml": caller(" with:\n flag: true\n"), | |||
| 269 | + | ".github/workflows/badtype.yml": caller(" with:\n n: abc\n"), | |||
| 270 | + | ".github/workflows/unknown.yml": caller(" with:\n n: 1\n zzz: 2\n"), | |||
| 271 | + | ".github/workflows/gone.yml": "on: push\njobs:\n c:\n uses: ./.github/workflows/nothere.yml\n", | |||
| 272 | + | ".github/workflows/ver.yml": "on: push\njobs:\n c:\n uses: ./.github/workflows/callee.yml@main\n", | |||
| 273 | + | } | |||
| 274 | + | c.push("main", files, "") | |||
| 275 | + | want := map[string]string{ | |||
| 276 | + | ".github/workflows/missing.yml": "workflow_call key is not defined in the referenced workflow.", | |||
| 277 | + | ".github/workflows/noreq.yml": "Input n is required, but not provided while calling.", | |||
| 278 | + | ".github/workflows/badtype.yml": "Unexpected value 'abc'", | |||
| 279 | + | ".github/workflows/unknown.yml": "Invalid input, zzz is not defined in the referenced workflow.", | |||
| 280 | + | ".github/workflows/gone.yml": "Unable to find reusable workflow", | |||
| 281 | + | ".github/workflows/ver.yml": "cannot specify version when calling local workflows", | |||
| 282 | + | } | |||
| 283 | + | runs := map[string]*pb.Run{} | |||
| 284 | + | c.waitFor("the runs exist", func() bool { | |||
| 285 | + | for _, r := range c.runs() { | |||
| 286 | + | runs[r.GetWorkflowPath()] = r | |||
| 287 | + | } | |||
| 288 | + | for p := range want { | |||
| 289 | + | if runs[p] == nil { | |||
| 290 | + | return false | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | return true | |||
| 294 | + | }) | |||
| 295 | + | for path, text := range want { | |||
| 296 | + | c.waitRunDone(runs[path].GetId()) | |||
| 297 | + | j := jobByKey(c, runs[path].GetId(), "c") | |||
| 298 | + | require.NotNil(t, j, path) | |||
| 299 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion(), path) | |||
| 300 | + | assert.Contains(t, j.GetError(), text, path) | |||
| 301 | + | } | |||
| 302 | + | assert.Contains(t, jobByKey(c, runs[".github/workflows/noreq.yml"].GetId(), "c").GetError(), "In .github/workflows/noreq.yml (Line: 4, Col: 11): Error from called workflow") | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // AC6: depth 5 fails with the documented error; depth 4 runs. | |||
| 306 | + | func TestCIReusableDepth(t *testing.T) { | |||
| 307 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 308 | + | files := map[string]string{} | |||
| 309 | + | for i := 1; i <= 5; i++ { | |||
| 310 | + | body := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo leaf\n" | |||
| 311 | + | if i < 5 { | |||
| 312 | + | body = "on:\n workflow_call:\njobs:\n next:\n uses: ./.github/workflows/l" + string(rune('0'+i+1)) + ".yml\n" | |||
| 313 | + | } | |||
| 314 | + | files[".github/workflows/l"+string(rune('0'+i))+".yml"] = body | |||
| 315 | + | } | |||
| 316 | + | files[".github/workflows/top4.yml"] = "on: push\njobs:\n a:\n uses: ./.github/workflows/l2.yml\n" | |||
| 317 | + | files[".github/workflows/top5.yml"] = "on: push\njobs:\n a:\n uses: ./.github/workflows/l1.yml\n" | |||
| 318 | + | delete(files, ".github/workflows/l5.yml") | |||
| 319 | + | files[".github/workflows/l5.yml"] = "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo leaf\n" | |||
| 320 | + | c.push("main", files, "") | |||
| 321 | + | var top4, top5 *pb.Run | |||
| 322 | + | c.waitFor("both runs", func() bool { | |||
| 323 | + | for _, r := range c.runs() { | |||
| 324 | + | switch r.GetWorkflowPath() { | |||
| 325 | + | case ".github/workflows/top4.yml": | |||
| 326 | + | top4 = r | |||
| 327 | + | case ".github/workflows/top5.yml": | |||
| 328 | + | top5 = r | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | return top4 != nil && top5 != nil | |||
| 332 | + | }) | |||
| 333 | + | // top4: a(1) -> l2(2) -> l3(3) -> l4(4) -> l5 (leaf): the leaf runs at depth 4. | |||
| 334 | + | leaf := awaitRecord(c, runner, top4.GetId(), "a/next/next/next/j") | |||
| 335 | + | runner.runJob(leaf, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 336 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(top4.GetId()).GetConclusion()) | |||
| 337 | + | ||||
| 338 | + | // top5: one more level: l1 -> ... -> l5 holds a call at depth 5. | |||
| 339 | + | c.waitRunDone(top5.GetId()) | |||
| 340 | + | var failed *pb.RunJob | |||
| 341 | + | for _, j := range c.getRun(top5.GetId()).GetJobs() { | |||
| 342 | + | if j.GetConclusion() == pb.CiConclusion_CI_CONCLUSION_FAILURE && strings.HasSuffix(j.GetKey(), "/next") { | |||
| 343 | + | failed = j | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | require.NotNil(t, failed, "the call that is too deep fails") | |||
| 347 | + | assert.Contains(t, failed.GetError(), "Nested reusable workflow depth exceeded 4.") | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | const ghCallee = `on: | |||
| 351 | + | workflow_call: | |||
| 352 | + | inputs: | |||
| 353 | + | go-version: | |||
| 354 | + | type: string | |||
| 355 | + | default: stable | |||
| 356 | + | jobs: | |||
| 357 | + | test: | |||
| 358 | + | runs-on: ubuntu-latest | |||
| 359 | + | steps: | |||
| 360 | + | - run: echo ${{ inputs.go-version }} | |||
| 361 | + | ` | |||
| 362 | + | ||||
| 363 | + | // AC6: a github.com callee, resolved with the CI-2 ref resolution and fetched | |||
| 364 | + | // at the resolved commit through the fetcher port (a fake here). | |||
| 365 | + | func TestCIReusableFromGitHub(t *testing.T) { | |||
| 366 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |||
| 367 | + | remote.set("acme/gh-actions", ptrRef("refs/heads/main", shaMain)) | |||
| 368 | + | wf.set("acme/gh-actions@"+shaMain+":.github/workflows/go-ci.yml", ghCallee) | |||
| 369 | + | c, runner := newReusableHarness(t, remote, wf) | |||
| 370 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n ci:\n uses: acme/gh-actions/.github/workflows/go-ci.yml@main\n with:\n go-version: '1.27'\n"}, "") | |||
| 371 | + | var r *pb.Run | |||
| 372 | + | c.waitFor("the run", func() bool { | |||
| 373 | + | if rs := c.runs(); len(rs) > 0 { | |||
| 374 | + | r = rs[0] | |||
| 375 | + | } | |||
| 376 | + | return r != nil | |||
| 377 | + | }) | |||
| 378 | + | j := awaitRecord(c, runner, r.GetId(), "ci/test") | |||
| 379 | + | assert.Contains(t, j.GetContexts(), `"inputs":{"go-version":"1.27"}`) | |||
| 380 | + | assert.Contains(t, j.GetContexts(), `"workflow_ref":"acme/gh-actions/.github/workflows/go-ci.yml@main"`) | |||
| 381 | + | assert.Contains(t, j.GetContexts(), `"workflow_sha":"`+shaMain+`"`) | |||
| 382 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 383 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r.GetId()).GetConclusion()) | |||
| 384 | + | assert.True(t, remote.asked("acme/gh-actions")) | |||
| 385 | + | wf.mu.Lock() | |||
| 386 | + | assert.Equal(t, []string{"acme/gh-actions@" + shaMain + ":.github/workflows/go-ci.yml"}, wf.calls) | |||
| 387 | + | wf.mu.Unlock() | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // AC6: an owner repository of the server wins over github.com. | |||
| 391 | + | func TestCIReusableFromAnErmiteRepository(t *testing.T) { | |||
| 392 | + | remote, wf := newFakeRemote(t), newFakeWorkflows(t) | |||
| 393 | + | remote.set("owner/shared", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite | |||
| 394 | + | c, runner := newReusableHarness(t, remote, wf) | |||
| 395 | + | c.seed("shared", domain.VisibilityPrivate) | |||
| 396 | + | wt := c.workTree("main") | |||
| 397 | + | c.writeFile(wt, ".github/workflows/lib.yml", []byte(ghCallee)) | |||
| 398 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/shared.git"), "main") | |||
| 399 | + | ||||
| 400 | + | c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n lib:\n uses: owner/shared/.github/workflows/lib.yml@main\n"}, "") | |||
| 401 | + | var r *pb.Run | |||
| 402 | + | c.waitFor("the run", func() bool { | |||
| 403 | + | if rs := c.runs(); len(rs) > 0 { | |||
| 404 | + | r = rs[0] | |||
| 405 | + | } | |||
| 406 | + | return r != nil | |||
| 407 | + | }) | |||
| 408 | + | j := awaitRecord(c, runner, r.GetId(), "lib/test") | |||
| 409 | + | assert.Contains(t, j.GetContexts(), `"inputs":{"go-version":"stable"}`, "the default") | |||
| 410 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 411 | + | c.waitRunDone(r.GetId()) | |||
| 412 | + | assert.False(t, remote.asked("owner/shared")) | |||
| 413 | + | wf.mu.Lock() | |||
| 414 | + | assert.Empty(t, wf.calls, "github.com is not asked for an ermite repository") | |||
| 415 | + | wf.mu.Unlock() | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | // A matrix on the calling job expands the callee once per leg. | |||
| 419 | + | func TestCIReusableMatrixCaller(t *testing.T) { | |||
| 420 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 421 | + | callee := "on:\n workflow_call:\n inputs:\n v:\n type: string\n required: true\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ inputs.v }}\n" | |||
| 422 | + | caller := "on: push\njobs:\n c:\n strategy:\n matrix:\n v: [a, b]\n uses: ./.github/workflows/callee.yml\n with:\n v: ${{ matrix.v }}\n" | |||
| 423 | + | c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": caller}, "") | |||
| 424 | + | var r *pb.Run | |||
| 425 | + | c.waitFor("the run", func() bool { | |||
| 426 | + | for _, x := range c.runs() { | |||
| 427 | + | if x.GetWorkflowPath() == ".github/workflows/ci.yml" { | |||
| 428 | + | r = x | |||
| 429 | + | } | |||
| 430 | + | } | |||
| 431 | + | return r != nil | |||
| 432 | + | }) | |||
| 433 | + | got := map[string]bool{} | |||
| 434 | + | for range 2 { | |||
| 435 | + | j := awaitRecord(c, runner, r.GetId(), "c/j") | |||
| 436 | + | switch { | |||
| 437 | + | case strings.Contains(j.GetContexts(), `"inputs":{"v":"a"}`): | |||
| 438 | + | got["a"] = true | |||
| 439 | + | case strings.Contains(j.GetContexts(), `"inputs":{"v":"b"}`): | |||
| 440 | + | got["b"] = true | |||
| 441 | + | } | |||
| 442 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 443 | + | } | |||
| 444 | + | assert.Equal(t, map[string]bool{"a": true, "b": true}, got) | |||
| 445 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r.GetId()).GetConclusion()) | |||
| 446 | + | names := map[string]bool{} | |||
| 447 | + | for _, j := range c.getRun(r.GetId()).GetJobs() { | |||
| 448 | + | names[j.GetName()] = true | |||
| 449 | + | } | |||
| 450 | + | assert.True(t, names["c (a) / j"] && names["c (b) / j"], "%v", names) | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | // D3: the callee can only lower the caller's permissions; a request above is | |||
| 454 | + | // refused with the runner's text, and a lower one is kept. | |||
| 455 | + | func TestCIReusablePermissionsOnlyLowered(t *testing.T) { | |||
| 456 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 457 | + | callee := func(perm string) string { | |||
| 458 | + | return "on:\n workflow_call:\n" + perm + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n" | |||
| 459 | + | } | |||
| 460 | + | caller := func(perm, callee string) string { | |||
| 461 | + | return "on: push\n" + perm + "jobs:\n c:\n uses: ./.github/workflows/" + callee + ".yml\n" | |||
| 462 | + | } | |||
| 463 | + | c.push("main", map[string]string{ | |||
| 464 | + | ".github/workflows/lower.yml": callee("permissions:\n contents: read\n"), | |||
| 465 | + | ".github/workflows/higher.yml": callee("permissions:\n contents: write\n"), | |||
| 466 | + | ".github/workflows/plain.yml": callee(""), | |||
| 467 | + | ".github/workflows/a.yml": caller("permissions:\n contents: write\n pull-requests: write\n", "lower"), | |||
| 468 | + | ".github/workflows/b.yml": caller("permissions:\n contents: read\n", "higher"), | |||
| 469 | + | ".github/workflows/p.yml": caller("permissions:\n contents: write\n", "plain"), | |||
| 470 | + | }, "") | |||
| 471 | + | runs := map[string]*pb.Run{} | |||
| 472 | + | c.waitFor("three runs", func() bool { | |||
| 473 | + | for _, r := range c.runs() { | |||
| 474 | + | runs[r.GetWorkflowPath()] = r | |||
| 475 | + | } | |||
| 476 | + | return runs[".github/workflows/a.yml"] != nil && runs[".github/workflows/b.yml"] != nil && runs[".github/workflows/p.yml"] != nil | |||
| 477 | + | }) | |||
| 478 | + | lower := awaitRecord(c, runner, runs[".github/workflows/a.yml"].GetId(), "c/j") | |||
| 479 | + | assert.Equal(t, "read", lower.GetPermissions().GetLevels()["contents"], "the callee lowers") | |||
| 480 | + | assert.NotContains(t, lower.GetPermissions().GetLevels(), "pull-requests") | |||
| 481 | + | runner.runJob(lower, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 482 | + | ||||
| 483 | + | plain := awaitRecord(c, runner, runs[".github/workflows/p.yml"].GetId(), "c/j") | |||
| 484 | + | assert.Equal(t, "write", plain.GetPermissions().GetLevels()["contents"], "a callee without permissions keeps the caller's") | |||
| 485 | + | runner.runJob(plain, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 486 | + | ||||
| 487 | + | c.waitRunDone(runs[".github/workflows/b.yml"].GetId()) | |||
| 488 | + | var errText string | |||
| 489 | + | for _, j := range c.getRun(runs[".github/workflows/b.yml"].GetId()).GetJobs() { | |||
| 490 | + | if j.GetKey() == "c/j" { | |||
| 491 | + | errText = j.GetError() | |||
| 492 | + | } | |||
| 493 | + | } | |||
| 494 | + | assert.Contains(t, errText, "is requesting 'contents: write', but is only allowed 'contents: read'.") | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | // D4: the job-level concurrency of a callee's job applies. | |||
| 498 | + | func TestCIReusableCalleeJobConcurrency(t *testing.T) { | |||
| 499 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 500 | + | callee := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n concurrency:\n group: callee-job-group\n steps:\n - run: echo\n" | |||
| 501 | + | caller := "on: push\njobs:\n c:\n uses: ./.github/workflows/callee.yml\n" | |||
| 502 | + | c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": caller}, "") | |||
| 503 | + | r1 := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 504 | + | j1 := awaitRecord(c, runner, r1.GetId(), "c/j") | |||
| 505 | + | assert.Equal(t, "callee-job-group", jobByKey(c, r1.GetId(), "c/j").GetConcurrencyGroup()) | |||
| 506 | + | c.push("main", map[string]string{"x.txt": "1"}, "second") | |||
| 507 | + | r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId()) | |||
| 508 | + | c.waitFor("the second run's job is pending on the group", func() bool { | |||
| 509 | + | j := jobByKey(c, r2.GetId(), "c/j") | |||
| 510 | + | return j != nil && j.GetStatus() == pb.CiStatus_CI_STATUS_PENDING | |||
| 511 | + | }) | |||
| 512 | + | runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 513 | + | j2 := awaitRecord(c, runner, r2.GetId(), "c/j") | |||
| 514 | + | runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 515 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion()) | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | // awaitRunOf waits for the newest run of a workflow file whose id is above after. | |||
| 519 | + | func (c *ciHarness) awaitRunOf(path string, after int64) *pb.Run { | |||
| 520 | + | c.t.Helper() | |||
| 521 | + | var r *pb.Run | |||
| 522 | + | c.waitFor("a run of "+path, func() bool { | |||
| 523 | + | for _, x := range c.runs() { | |||
| 524 | + | if x.GetWorkflowPath() == path && x.GetId() > after { | |||
| 525 | + | r = x | |||
| 526 | + | return true | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | return false | |||
| 530 | + | }) | |||
| 531 | + | return r | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | const concCallee = `on: | |||
| 535 | + | workflow_call: | |||
| 536 | + | inputs: | |||
| 537 | + | env: | |||
| 538 | + | type: string | |||
| 539 | + | default: prod | |||
| 540 | + | %s | |||
| 541 | + | jobs: | |||
| 542 | + | j: | |||
| 543 | + | runs-on: ubuntu-latest | |||
| 544 | + | steps: | |||
| 545 | + | - run: echo | |||
| 546 | + | ` | |||
| 547 | + | ||||
| 548 | + | func concCaller(top string) string { | |||
| 549 | + | return "on: push\njobs:\n c:\n" + top + " uses: ./.github/workflows/callee.yml\n with:\n env: ${{ github.ref_name }}\n" | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | // D4: the concurrency of the calling job, and the workflow-level concurrency of | |||
| 553 | + | // the called workflow (evaluated with the callee's inputs), hold the whole call: | |||
| 554 | + | // its jobs are not assignable until the call has the group, and the group is | |||
| 555 | + | // freed when the call ends. Both groups are tested in turn. | |||
| 556 | + | func TestCIReusableCallConcurrency(t *testing.T) { | |||
| 557 | + | for _, tt := range []struct{ name, caller, callee, group string }{ | |||
| 558 | + | {"calling job", " concurrency:\n group: call-group\n", "", "call-group"}, | |||
| 559 | + | {"called workflow", "", "concurrency: deploy-${{ inputs.env }}\n", "deploy-main"}, | |||
| 560 | + | } { | |||
| 561 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 562 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 563 | + | callee := strings.Replace(concCallee, "%s\n", tt.callee, 1) | |||
| 564 | + | files := map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": concCaller(tt.caller)} | |||
| 565 | + | c.push("main", files, "") | |||
| 566 | + | r1 := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 567 | + | j1 := awaitRecord(c, runner, r1.GetId(), "c/j") | |||
| 568 | + | ||||
| 569 | + | c.push("main", map[string]string{"x.txt": "1"}, "second") | |||
| 570 | + | r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId()) | |||
| 571 | + | c.waitFor("the second call waits for the group", func() bool { | |||
| 572 | + | call := jobByKey(c, r2.GetId(), "c") | |||
| 573 | + | return call != nil && call.GetStatus() == pb.CiStatus_CI_STATUS_PENDING | |||
| 574 | + | }) | |||
| 575 | + | // Its jobs exist but are not assignable; they show the group. | |||
| 576 | + | j, err := runner.fetch("") | |||
| 577 | + | require.NoError(t, err) | |||
| 578 | + | assert.Nil(t, j, "no job of the second call is given while the first holds the group") | |||
| 579 | + | ||||
| 580 | + | runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 581 | + | j2 := awaitRecord(c, runner, r2.GetId(), "c/j") | |||
| 582 | + | runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 583 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion()) | |||
| 584 | + | ||||
| 585 | + | // Freed with the call: a third call takes it at once. | |||
| 586 | + | c.push("main", map[string]string{"y.txt": "1"}, "third") | |||
| 587 | + | r3 := c.awaitRunOf(".github/workflows/ci.yml", r2.GetId()) | |||
| 588 | + | j3 := awaitRecord(c, runner, r3.GetId(), "c/j") | |||
| 589 | + | runner.runJob(j3, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 590 | + | c.waitRunDone(r3.GetId()) | |||
| 591 | + | }) | |||
| 592 | + | } | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | // D4: cancel-in-progress on a call cancels the running call: its running job is | |||
| 596 | + | // asked to stop, the call ends cancelled with GitHub's annotation, and the | |||
| 597 | + | // waiting call takes the group. | |||
| 598 | + | func TestCIReusableCallCancelInProgress(t *testing.T) { | |||
| 599 | + | for _, tt := range []struct{ name, caller, callee string }{ | |||
| 600 | + | {"calling job", " concurrency:\n group: Cip\n cancel-in-progress: true\n", ""}, | |||
| 601 | + | {"called workflow", "", "concurrency:\n group: Cip\n cancel-in-progress: true\n"}, | |||
| 602 | + | } { | |||
| 603 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 604 | + | c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t)) | |||
| 605 | + | callee := strings.Replace(concCallee, "%s\n", tt.callee, 1) | |||
| 606 | + | c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": concCaller(tt.caller)}, "") | |||
| 607 | + | r1 := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 608 | + | j1 := awaitRecord(c, runner, r1.GetId(), "c/j") | |||
| 609 | + | watch, err := runner.jobAPI(j1).runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: j1.GetJobId()})) | |||
| 610 | + | require.NoError(t, err) | |||
| 611 | + | ||||
| 612 | + | c.push("main", map[string]string{"x.txt": "1"}, "second") | |||
| 613 | + | r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId()) | |||
| 614 | + | var cancel *pb.CancelRequest | |||
| 615 | + | for watch.Receive() { | |||
| 616 | + | if m := watch.Msg().GetCancel(); m != nil { | |||
| 617 | + | cancel = m | |||
| 618 | + | break | |||
| 619 | + | } | |||
| 620 | + | } | |||
| 621 | + | require.NotNil(t, cancel, "the running job of the call is asked to stop") | |||
| 622 | + | assert.Contains(t, cancel.GetMessage(), "higher priority waiting request for 'Cip'") | |||
| 623 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, c.cancelledCall(r1), "the call ends cancelled") | |||
| 624 | + | ||||
| 625 | + | _, err = runner.jobAPI(j1).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{ | |||
| 626 | + | JobId: j1.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED, | |||
| 627 | + | })) | |||
| 628 | + | require.NoError(t, err) | |||
| 629 | + | j2 := awaitRecord(c, runner, r2.GetId(), "c/j") | |||
| 630 | + | runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 631 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion()) | |||
| 632 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, c.waitRunDone(r1.GetId()).GetConclusion()) | |||
| 633 | + | }) | |||
| 634 | + | } | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | // cancelledCall waits for the call record "c" of a run to end and returns its conclusion. | |||
| 638 | + | func (c *ciHarness) cancelledCall(r *pb.Run) pb.CiConclusion { | |||
| 639 | + | c.t.Helper() | |||
| 640 | + | c.waitFor("the call ends", func() bool { | |||
| 641 | + | j := jobByKey(c, r.GetId(), "c") | |||
| 642 | + | return j != nil && j.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED | |||
| 643 | + | }) | |||
| 644 | + | return jobByKey(c, r.GetId(), "c").GetConclusion() | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | // A private repository of the server that the actor cannot read is not a | |||
| 648 | + | // source of reusable workflows: the call fails as for a repository that does | |||
| 649 | + | // not exist, and a reader gets it. | |||
| 650 | + | func TestCIReusableFromAPrivateErmiteRepository(t *testing.T) { | |||
| 651 | + | for _, tt := range []struct { | |||
| 652 | + | name string | |||
| 653 | + | token func(c *ciHarness) (string, string) | |||
| 654 | + | reads bool | |||
| 655 | + | }{ | |||
| 656 | + | {"an owner token", nil, true}, | |||
| 657 | + | {"a token that lists the caller only", func(c *ciHarness) (string, string) { | |||
| 658 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |||
| 659 | + | }, false}, | |||
| 660 | + | {"a token that lists both", func(c *ciHarness) (string, string) { | |||
| 661 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "shared") | |||
| 662 | + | }, true}, | |||
| 663 | + | } { | |||
| 664 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 665 | + | remote := newFakeRemote(t) | |||
| 666 | + | remote.set("owner/shared", ptrRef("refs/heads/main", shaMain)) | |||
| 667 | + | wf := newFakeWorkflows(t) | |||
| 668 | + | c, runner := newReusableHarness(t, remote, wf) | |||
| 669 | + | c.seed("shared", domain.VisibilityPrivate) | |||
| 670 | + | wt := c.workTree("main") | |||
| 671 | + | c.writeFile(wt, ".github/workflows/lib.yml", []byte(ghCallee)) | |||
| 672 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/shared.git"), "main") | |||
| 673 | + | secret := adminToken | |||
| 674 | + | if tt.token != nil { | |||
| 675 | + | secret, _ = tt.token(c) | |||
| 676 | + | } | |||
| 677 | + | c.pushAs(secret, "main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n lib:\n uses: owner/shared/.github/workflows/lib.yml@main\n"}) | |||
| 678 | + | r := c.awaitRunOf(".github/workflows/ci.yml", 0) | |||
| 679 | + | if tt.reads { | |||
| 680 | + | j := awaitRecord(c, runner, r.GetId(), "lib/test") | |||
| 681 | + | runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil) | |||
| 682 | + | c.waitRunDone(r.GetId()) | |||
| 683 | + | return | |||
| 684 | + | } | |||
| 685 | + | c.waitRunDone(r.GetId()) | |||
| 686 | + | j := jobByKey(c, r.GetId(), "lib") | |||
| 687 | + | require.NotNil(t, j) | |||
| 688 | + | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | |||
| 689 | + | assert.Equal(t, "Unable to resolve action `owner/shared@main`, repository not found", j.GetError(), | |||
| 690 | + | "the text of a repository that is not there, nothing about the private one") | |||
| 691 | + | assert.False(t, remote.asked("owner/shared"), "never falls through to github.com") | |||
| 692 | + | wf.mu.Lock() | |||
| 693 | + | assert.Empty(t, wf.calls) | |||
| 694 | + | wf.mu.Unlock() | |||
| 695 | + | }) | |||
| 696 | + | } | |||
| 697 | + | } | |||
M internal/app/forge.go +16 -2
| @@ -22,6 +22,7 @@ import ( | |||
| 22 | 22 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 23 | 23 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 24 | 24 | "bnema.dev/ermite/internal/adapters/githubapi" | |
| 25 | + | "bnema.dev/ermite/internal/adapters/githubraw" | |
| 25 | 26 | "bnema.dev/ermite/internal/adapters/gitremote" | |
| 26 | 27 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 27 | 28 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| @@ -60,6 +61,9 @@ type options struct { | |||
| 60 | 61 | // remote replaces the lister of github.com refs (tests never reach the | |
| 61 | 62 | // network). | |
| 62 | 63 | remoteLister ports.GitRemote | |
| 64 | + | // workflowFetcher replaces the downloader of github.com reusable workflows | |
| 65 | + | // (tests never reach the network). | |
| 66 | + | workflowFetcher ports.WorkflowFetcher | |
| 63 | 67 | // ci overrides the settings of the CI engine (tests shorten its | |
| 64 | 68 | // durations). | |
| 65 | 69 | ci func(*usecase.CIConfig) | |
| @@ -73,6 +77,15 @@ func (o options) remote(def ports.GitRemote) ports.GitRemote { | |||
| 73 | 77 | return def | |
| 74 | 78 | } | |
| 75 | 79 | ||
| 80 | + | // workflows returns the downloader of github.com reusable workflows: the | |
| 81 | + | // test's, else def. | |
| 82 | + | func (o options) workflows(def ports.WorkflowFetcher) ports.WorkflowFetcher { | |
| 83 | + | if o.workflowFetcher != nil { | |
| 84 | + | return o.workflowFetcher | |
| 85 | + | } | |
| 86 | + | return def | |
| 87 | + | } | |
| 88 | + | ||
| 76 | 89 | // forge is the wired server: every adapter and use case, ready to serve. | |
| 77 | 90 | type forge struct { | |
| 78 | 91 | cfg config.Config | |
| @@ -241,8 +254,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 241 | 254 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | |
| 242 | 255 | Proxy: gitremote.ProxyEnv(os.Getenv), | |
| 243 | 256 | })), | |
| 244 | - | Tokens: store.Tokens(), | |
| 245 | - | Settings: f.Settings, | |
| 257 | + | Tokens: store.Tokens(), | |
| 258 | + | Settings: f.Settings, | |
| 259 | + | Workflows: opts.workflows(githubraw.New(githubraw.Config{})), | |
| 246 | 260 | } | |
| 247 | 261 | if opts.ci != nil { | |
| 248 | 262 | opts.ci(&ciCfg) | |
| @@ -22,6 +22,7 @@ import ( | |||||
| 22 | "bnema.dev/ermite/internal/adapters/gitcli" | 22 | "bnema.dev/ermite/internal/adapters/gitcli" | ||
| 23 | "bnema.dev/ermite/internal/adapters/githttp" | 23 | "bnema.dev/ermite/internal/adapters/githttp" | ||
| 24 | "bnema.dev/ermite/internal/adapters/githubapi" | 24 | "bnema.dev/ermite/internal/adapters/githubapi" | ||
| 25 | + | "bnema.dev/ermite/internal/adapters/githubraw" | |||
| 25 | "bnema.dev/ermite/internal/adapters/gitremote" | 26 | "bnema.dev/ermite/internal/adapters/gitremote" | ||
| 26 | "bnema.dev/ermite/internal/adapters/httpserver" | 27 | "bnema.dev/ermite/internal/adapters/httpserver" | ||
| 27 | "bnema.dev/ermite/internal/adapters/kvstore" | 28 | "bnema.dev/ermite/internal/adapters/kvstore" | ||
| @@ -60,6 +61,9 @@ type options struct { | |||||
| 60 | // remote replaces the lister of github.com refs (tests never reach the | 61 | // remote replaces the lister of github.com refs (tests never reach the | ||
| 61 | // network). | 62 | // network). | ||
| 62 | remoteLister ports.GitRemote | 63 | remoteLister ports.GitRemote | ||
| 64 | + | // workflowFetcher replaces the downloader of github.com reusable workflows | |||
| 65 | + | // (tests never reach the network). | |||
| 66 | + | workflowFetcher ports.WorkflowFetcher | |||
| 63 | // ci overrides the settings of the CI engine (tests shorten its | 67 | // ci overrides the settings of the CI engine (tests shorten its | ||
| 64 | // durations). | 68 | // durations). | ||
| 65 | ci func(*usecase.CIConfig) | 69 | ci func(*usecase.CIConfig) | ||
| @@ -73,6 +77,15 @@ func (o options) remote(def ports.GitRemote) ports.GitRemote { | |||||
| 73 | return def | 77 | return def | ||
| 74 | } | 78 | } | ||
| 75 | 79 | ||||
| 80 | + | // workflows returns the downloader of github.com reusable workflows: the | |||
| 81 | + | // test's, else def. | |||
| 82 | + | func (o options) workflows(def ports.WorkflowFetcher) ports.WorkflowFetcher { | |||
| 83 | + | if o.workflowFetcher != nil { | |||
| 84 | + | return o.workflowFetcher | |||
| 85 | + | } | |||
| 86 | + | return def | |||
| 87 | + | } | |||
| 88 | + | ||||
| 76 | // forge is the wired server: every adapter and use case, ready to serve. | 89 | // forge is the wired server: every adapter and use case, ready to serve. | ||
| 77 | type forge struct { | 90 | type forge struct { | ||
| 78 | cfg config.Config | 91 | cfg config.Config | ||
| @@ -241,8 +254,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 241 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | 254 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | ||
| 242 | Proxy: gitremote.ProxyEnv(os.Getenv), | 255 | Proxy: gitremote.ProxyEnv(os.Getenv), | ||
| 243 | })), | 256 | })), | ||
| 244 | - | Tokens: store.Tokens(), | 257 | + | Tokens: store.Tokens(), |
| 245 | - | Settings: f.Settings, | 258 | + | Settings: f.Settings, |
| 259 | + | Workflows: opts.workflows(githubraw.New(githubraw.Config{})), | |||
| 246 | } | 260 | } | ||
| 247 | if opts.ci != nil { | 261 | if opts.ci != nil { | ||
| 248 | opts.ci(&ciCfg) | 262 | opts.ci(&ciCfg) | ||
M internal/domain/ci.go +46 -0
| @@ -288,6 +288,15 @@ type CIJob struct { | |||
| 288 | 288 | // the job is planned, else the one of its run (FromRun). | |
| 289 | 289 | Concurrency CIConcurrency | |
| 290 | 290 | ||
| 291 | + | // Scope is the id of the record of the job that called the reusable workflow | |
| 292 | + | // this job belongs to, "" for a job of the run's own workflow. CallID is the | |
| 293 | + | // id of the job in that called workflow (Key is "<caller key>/<call id>"). | |
| 294 | + | Scope string | |
| 295 | + | CallID string | |
| 296 | + | // Call is set on the record of a job that calls a reusable workflow: it never | |
| 297 | + | // runs, it ends when the jobs of its scope have. | |
| 298 | + | Call *CICall | |
| 299 | + | ||
| 291 | 300 | // TokenHash is the hash of the job token of the current assignment; zero | |
| 292 | 301 | // when there is none. | |
| 293 | 302 | TokenHash TokenHash | |
| @@ -305,6 +314,37 @@ type CIJob struct { | |||
| 305 | 314 | Version uint64 | |
| 306 | 315 | } | |
| 307 | 316 | ||
| 317 | + | // CICall is what a job calling a reusable workflow resolved (CI-3, D7). | |
| 318 | + | type CICall struct { | |
| 319 | + | Uses string | |
| 320 | + | // Path is the file in its repository, Repository "owner/repo" for github.com | |
| 321 | + | // or the name on this server, Ref what the workflow wrote ("" for a local | |
| 322 | + | // call), Sha the commit the call resolved to. | |
| 323 | + | Path, Repository, Ref, Sha string | |
| 324 | + | YAML string | |
| 325 | + | // InputsJSON is the inputs context of the called workflow. | |
| 326 | + | InputsJSON string | |
| 327 | + | // Secrets holds the names (upper case) of the secrets of the called workflow | |
| 328 | + | // that the calling workflow's "secrets:" gives, with empty values: the | |
| 329 | + | // expressions are read from the pinned calling workflow when a job is | |
| 330 | + | // assigned. Inherit: "secrets: inherit". | |
| 331 | + | Secrets map[string]string | |
| 332 | + | Inherit bool | |
| 333 | + | // Depth is 1 for a call from the run's own workflow. | |
| 334 | + | Depth int | |
| 335 | + | // Expanded: the jobs of the called workflow exist. | |
| 336 | + | Expanded bool | |
| 337 | + | // Concurrency is the workflow-level group of the called workflow. The call | |
| 338 | + | // holds it, with the group of the calling job (CIJob.Concurrency), until it | |
| 339 | + | // ends; its jobs wait for both. | |
| 340 | + | Concurrency CIConcurrency | |
| 341 | + | // Admitted: the call may start, the max-parallel of the calling job's | |
| 342 | + | // strategy allows it. | |
| 343 | + | Admitted bool | |
| 344 | + | // SecretCtx is the JSON of the other contexts the Secrets expressions read. | |
| 345 | + | SecretCtx string | |
| 346 | + | } | |
| 347 | + | ||
| 308 | 348 | // Status is the status owners see: a job is queued until its runner | |
| 309 | 349 | // acknowledged it. | |
| 310 | 350 | func (j CIJob) Status() CIStatus { | |
| @@ -317,6 +357,12 @@ func (j CIJob) Status() CIStatus { | |||
| 317 | 357 | if j.Concurrency.State == CIConcurrencyPending { | |
| 318 | 358 | return CIPending | |
| 319 | 359 | } | |
| 360 | + | if j.Call != nil && j.Call.Concurrency.State == CIConcurrencyPending { | |
| 361 | + | return CIPending | |
| 362 | + | } | |
| 363 | + | if j.Call != nil && j.Call.Expanded && j.Call.Admitted { | |
| 364 | + | return CIInProgress | |
| 365 | + | } | |
| 320 | 366 | } | |
| 321 | 367 | return CIQueued | |
| 322 | 368 | } | |
| @@ -288,6 +288,15 @@ type CIJob struct { | |||||
| 288 | // the job is planned, else the one of its run (FromRun). | 288 | // the job is planned, else the one of its run (FromRun). | ||
| 289 | Concurrency CIConcurrency | 289 | Concurrency CIConcurrency | ||
| 290 | 290 | ||||
| 291 | + | // Scope is the id of the record of the job that called the reusable workflow | |||
| 292 | + | // this job belongs to, "" for a job of the run's own workflow. CallID is the | |||
| 293 | + | // id of the job in that called workflow (Key is "<caller key>/<call id>"). | |||
| 294 | + | Scope string | |||
| 295 | + | CallID string | |||
| 296 | + | // Call is set on the record of a job that calls a reusable workflow: it never | |||
| 297 | + | // runs, it ends when the jobs of its scope have. | |||
| 298 | + | Call *CICall | |||
| 299 | + | ||||
| 291 | // TokenHash is the hash of the job token of the current assignment; zero | 300 | // TokenHash is the hash of the job token of the current assignment; zero | ||
| 292 | // when there is none. | 301 | // when there is none. | ||
| 293 | TokenHash TokenHash | 302 | TokenHash TokenHash | ||
| @@ -305,6 +314,37 @@ type CIJob struct { | |||||
| 305 | Version uint64 | 314 | Version uint64 | ||
| 306 | } | 315 | } | ||
| 307 | 316 | ||||
| 317 | + | // CICall is what a job calling a reusable workflow resolved (CI-3, D7). | |||
| 318 | + | type CICall struct { | |||
| 319 | + | Uses string | |||
| 320 | + | // Path is the file in its repository, Repository "owner/repo" for github.com | |||
| 321 | + | // or the name on this server, Ref what the workflow wrote ("" for a local | |||
| 322 | + | // call), Sha the commit the call resolved to. | |||
| 323 | + | Path, Repository, Ref, Sha string | |||
| 324 | + | YAML string | |||
| 325 | + | // InputsJSON is the inputs context of the called workflow. | |||
| 326 | + | InputsJSON string | |||
| 327 | + | // Secrets holds the names (upper case) of the secrets of the called workflow | |||
| 328 | + | // that the calling workflow's "secrets:" gives, with empty values: the | |||
| 329 | + | // expressions are read from the pinned calling workflow when a job is | |||
| 330 | + | // assigned. Inherit: "secrets: inherit". | |||
| 331 | + | Secrets map[string]string | |||
| 332 | + | Inherit bool | |||
| 333 | + | // Depth is 1 for a call from the run's own workflow. | |||
| 334 | + | Depth int | |||
| 335 | + | // Expanded: the jobs of the called workflow exist. | |||
| 336 | + | Expanded bool | |||
| 337 | + | // Concurrency is the workflow-level group of the called workflow. The call | |||
| 338 | + | // holds it, with the group of the calling job (CIJob.Concurrency), until it | |||
| 339 | + | // ends; its jobs wait for both. | |||
| 340 | + | Concurrency CIConcurrency | |||
| 341 | + | // Admitted: the call may start, the max-parallel of the calling job's | |||
| 342 | + | // strategy allows it. | |||
| 343 | + | Admitted bool | |||
| 344 | + | // SecretCtx is the JSON of the other contexts the Secrets expressions read. | |||
| 345 | + | SecretCtx string | |||
| 346 | + | } | |||
| 347 | + | ||||
| 308 | // Status is the status owners see: a job is queued until its runner | 348 | // Status is the status owners see: a job is queued until its runner | ||
| 309 | // acknowledged it. | 349 | // acknowledged it. | ||
| 310 | func (j CIJob) Status() CIStatus { | 350 | func (j CIJob) Status() CIStatus { | ||
| @@ -317,6 +357,12 @@ func (j CIJob) Status() CIStatus { | |||||
| 317 | if j.Concurrency.State == CIConcurrencyPending { | 357 | if j.Concurrency.State == CIConcurrencyPending { | ||
| 318 | return CIPending | 358 | return CIPending | ||
| 319 | } | 359 | } | ||
| 360 | + | if j.Call != nil && j.Call.Concurrency.State == CIConcurrencyPending { | |||
| 361 | + | return CIPending | |||
| 362 | + | } | |||
| 363 | + | if j.Call != nil && j.Call.Expanded && j.Call.Admitted { | |||
| 364 | + | return CIInProgress | |||
| 365 | + | } | |||
| 320 | } | 366 | } | ||
| 321 | return CIQueued | 367 | return CIQueued | ||
| 322 | } | 368 | } | ||
M internal/domain/ci_permissions.go +36 -0
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package domain | |
| 4 | 4 | ||
| 5 | + | import "slices" | |
| 6 | + | ||
| 5 | 7 | // The permissions of a job repository token (CI-3, D3) are the keys and levels | |
| 6 | 8 | // of the "permissions:" of a GitHub workflow. They are computed once, when the | |
| 7 | 9 | // job is planned, and stored as a map from key to level; a key that is absent | |
| @@ -172,3 +174,37 @@ func RepoTokenScopes(perms map[string]string) []Scope { | |||
| 172 | 174 | } | |
| 173 | 175 | return scopes | |
| 174 | 176 | } | |
| 177 | + | ||
| 178 | + | // permissionRank orders the levels: none, read, write. | |
| 179 | + | func permissionRank(level string) int { | |
| 180 | + | switch level { | |
| 181 | + | case PermissionRead: | |
| 182 | + | return 1 | |
| 183 | + | case PermissionWrite: | |
| 184 | + | return 2 | |
| 185 | + | } | |
| 186 | + | return 0 | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // PermissionViolations lists the keys that requested asks for above what max | |
| 190 | + | // allows (an absent key is none): a reusable workflow can only keep or lower the | |
| 191 | + | // permissions of its caller. The two slices hold "key: level" in the same | |
| 192 | + | // order, sorted by key, as GitHub's error message shows them. | |
| 193 | + | func PermissionViolations(requested, max map[string]string) (asked, allowed []string) { | |
| 194 | + | keys := make([]string, 0, len(requested)) | |
| 195 | + | for k := range requested { | |
| 196 | + | keys = append(keys, k) | |
| 197 | + | } | |
| 198 | + | slices.Sort(keys) | |
| 199 | + | for _, k := range keys { | |
| 200 | + | if permissionRank(requested[k]) > permissionRank(max[k]) { | |
| 201 | + | lv := max[k] | |
| 202 | + | if lv == "" { | |
| 203 | + | lv = PermissionNone | |
| 204 | + | } | |
| 205 | + | asked = append(asked, k+": "+requested[k]) | |
| 206 | + | allowed = append(allowed, k+": "+lv) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | return asked, allowed | |
| 210 | + | } | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | package domain | 3 | package domain | ||
| 4 | 4 | ||||
| 5 | + | import "slices" | |||
| 6 | + | ||||
| 5 | // The permissions of a job repository token (CI-3, D3) are the keys and levels | 7 | // The permissions of a job repository token (CI-3, D3) are the keys and levels | ||
| 6 | // of the "permissions:" of a GitHub workflow. They are computed once, when the | 8 | // of the "permissions:" of a GitHub workflow. They are computed once, when the | ||
| 7 | // job is planned, and stored as a map from key to level; a key that is absent | 9 | // job is planned, and stored as a map from key to level; a key that is absent | ||
| @@ -172,3 +174,37 @@ func RepoTokenScopes(perms map[string]string) []Scope { | |||||
| 172 | } | 174 | } | ||
| 173 | return scopes | 175 | return scopes | ||
| 174 | } | 176 | } | ||
| 177 | + | ||||
| 178 | + | // permissionRank orders the levels: none, read, write. | |||
| 179 | + | func permissionRank(level string) int { | |||
| 180 | + | switch level { | |||
| 181 | + | case PermissionRead: | |||
| 182 | + | return 1 | |||
| 183 | + | case PermissionWrite: | |||
| 184 | + | return 2 | |||
| 185 | + | } | |||
| 186 | + | return 0 | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // PermissionViolations lists the keys that requested asks for above what max | |||
| 190 | + | // allows (an absent key is none): a reusable workflow can only keep or lower the | |||
| 191 | + | // permissions of its caller. The two slices hold "key: level" in the same | |||
| 192 | + | // order, sorted by key, as GitHub's error message shows them. | |||
| 193 | + | func PermissionViolations(requested, max map[string]string) (asked, allowed []string) { | |||
| 194 | + | keys := make([]string, 0, len(requested)) | |||
| 195 | + | for k := range requested { | |||
| 196 | + | keys = append(keys, k) | |||
| 197 | + | } | |||
| 198 | + | slices.Sort(keys) | |||
| 199 | + | for _, k := range keys { | |||
| 200 | + | if permissionRank(requested[k]) > permissionRank(max[k]) { | |||
| 201 | + | lv := max[k] | |||
| 202 | + | if lv == "" { | |||
| 203 | + | lv = PermissionNone | |||
| 204 | + | } | |||
| 205 | + | asked = append(asked, k+": "+requested[k]) | |||
| 206 | + | allowed = append(allowed, k+": "+lv) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | return asked, allowed | |||
| 210 | + | } | |||
M internal/domain/ci_permissions_test.go +11 -0
| @@ -136,3 +136,14 @@ func TestRepoTokenPrincipalFollowsItsPermissions(t *testing.T) { | |||
| 136 | 136 | assert.False(t, domain.Anonymous().ViaJobToken()) | |
| 137 | 137 | assert.False(t, domain.TokenPrincipal(domain.Token{ID: "0123456789abcdef"}).ViaJobToken()) | |
| 138 | 138 | } | |
| 139 | + | ||
| 140 | + | func TestPermissionViolations(t *testing.T) { | |
| 141 | + | t.Parallel() | |
| 142 | + | asked, allowed := domain.PermissionViolations( | |
| 143 | + | map[string]string{"contents": "write", "issues": "read", "packages": "read", "pages": "none"}, | |
| 144 | + | map[string]string{"contents": "read", "packages": "write"}) | |
| 145 | + | assert.Equal(t, []string{"contents: write", "issues: read"}, asked) | |
| 146 | + | assert.Equal(t, []string{"contents: read", "issues: none"}, allowed) | |
| 147 | + | asked, _ = domain.PermissionViolations(map[string]string{"contents": "read"}, map[string]string{"contents": "write"}) | |
| 148 | + | assert.Empty(t, asked, "lowering is allowed") | |
| 149 | + | } | |
| @@ -136,3 +136,14 @@ func TestRepoTokenPrincipalFollowsItsPermissions(t *testing.T) { | |||||
| 136 | assert.False(t, domain.Anonymous().ViaJobToken()) | 136 | assert.False(t, domain.Anonymous().ViaJobToken()) | ||
| 137 | assert.False(t, domain.TokenPrincipal(domain.Token{ID: "0123456789abcdef"}).ViaJobToken()) | 137 | assert.False(t, domain.TokenPrincipal(domain.Token{ID: "0123456789abcdef"}).ViaJobToken()) | ||
| 138 | } | 138 | } | ||
| 139 | + | ||||
| 140 | + | func TestPermissionViolations(t *testing.T) { | |||
| 141 | + | t.Parallel() | |||
| 142 | + | asked, allowed := domain.PermissionViolations( | |||
| 143 | + | map[string]string{"contents": "write", "issues": "read", "packages": "read", "pages": "none"}, | |||
| 144 | + | map[string]string{"contents": "read", "packages": "write"}) | |||
| 145 | + | assert.Equal(t, []string{"contents: write", "issues: read"}, asked) | |||
| 146 | + | assert.Equal(t, []string{"contents: read", "issues: none"}, allowed) | |||
| 147 | + | asked, _ = domain.PermissionViolations(map[string]string{"contents": "read"}, map[string]string{"contents": "write"}) | |||
| 148 | + | assert.Empty(t, asked, "lowering is allowed") | |||
| 149 | + | } | |||
M internal/ports/ci.go +9 -0
| @@ -438,6 +438,15 @@ type GitRemote interface { | |||
| 438 | 438 | ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error) | |
| 439 | 439 | } | |
| 440 | 440 | ||
| 441 | + | // WorkflowFetcher downloads one reusable workflow file from github.com at a | |
| 442 | + | // commit that was already resolved (CI-3, D7), with no credentials. It returns | |
| 443 | + | // domain.ErrNotFound for a file or repository that does not exist or needs | |
| 444 | + | // credentials, domain.ErrResourceExhausted for a file larger than maxBytes, and | |
| 445 | + | // an error matching domain.ErrUnavailable when it cannot tell. | |
| 446 | + | type WorkflowFetcher interface { | |
| 447 | + | FetchWorkflow(ctx context.Context, owner, repo, sha, path string, maxBytes int64) ([]byte, error) | |
| 448 | + | } | |
| 449 | + | ||
| 441 | 450 | // JobSignal is one message of WatchJob. | |
| 442 | 451 | type JobSignal struct { | |
| 443 | 452 | Keepalive bool | |
| @@ -438,6 +438,15 @@ type GitRemote interface { | |||||
| 438 | ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error) | 438 | ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error) | ||
| 439 | } | 439 | } | ||
| 440 | 440 | ||||
| 441 | + | // WorkflowFetcher downloads one reusable workflow file from github.com at a | |||
| 442 | + | // commit that was already resolved (CI-3, D7), with no credentials. It returns | |||
| 443 | + | // domain.ErrNotFound for a file or repository that does not exist or needs | |||
| 444 | + | // credentials, domain.ErrResourceExhausted for a file larger than maxBytes, and | |||
| 445 | + | // an error matching domain.ErrUnavailable when it cannot tell. | |||
| 446 | + | type WorkflowFetcher interface { | |||
| 447 | + | FetchWorkflow(ctx context.Context, owner, repo, sha, path string, maxBytes int64) ([]byte, error) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 441 | // JobSignal is one message of WatchJob. | 450 | // JobSignal is one message of WatchJob. | ||
| 442 | type JobSignal struct { | 451 | type JobSignal struct { | ||
| 443 | Keepalive bool | 452 | Keepalive bool | ||
A internal/ports/mocks/WorkflowFetcher.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewWorkflowFetcher creates a new instance of WorkflowFetcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewWorkflowFetcher(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *WorkflowFetcher { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &WorkflowFetcher{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { | |
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | + | helper.Helper() | |
| 29 | + | } | |
| 30 | + | mock.AssertExpectations(t) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | return mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // WorkflowFetcher is an autogenerated mock type for the WorkflowFetcher type | |
| 37 | + | type WorkflowFetcher struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type WorkflowFetcher_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *WorkflowFetcher) EXPECT() *WorkflowFetcher_Expecter { | |
| 46 | + | return &WorkflowFetcher_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // FetchWorkflow provides a mock function for the type WorkflowFetcher | |
| 50 | + | func (_mock *WorkflowFetcher) FetchWorkflow(ctx context.Context, owner string, repo string, sha string, path string, maxBytes int64) ([]byte, error) { | |
| 51 | + | ret := _mock.Called(ctx, owner, repo, sha, path, maxBytes) | |
| 52 | + | ||
| 53 | + | if len(ret) == 0 { | |
| 54 | + | panic("no return value specified for FetchWorkflow") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var r0 []byte | |
| 58 | + | var r1 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string, int64) ([]byte, error)); ok { | |
| 60 | + | return returnFunc(ctx, owner, repo, sha, path, maxBytes) | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string, int64) []byte); ok { | |
| 63 | + | r0 = returnFunc(ctx, owner, repo, sha, path, maxBytes) | |
| 64 | + | } else { | |
| 65 | + | if ret.Get(0) != nil { | |
| 66 | + | r0 = ret.Get(0).([]byte) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, string, int64) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, owner, repo, sha, path, maxBytes) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // WorkflowFetcher_FetchWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchWorkflow' | |
| 78 | + | type WorkflowFetcher_FetchWorkflow_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // FetchWorkflow is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - owner string | |
| 85 | + | // - repo string | |
| 86 | + | // - sha string | |
| 87 | + | // - path string | |
| 88 | + | // - maxBytes int64 | |
| 89 | + | func (_e *WorkflowFetcher_Expecter) FetchWorkflow(ctx any, owner any, repo any, sha any, path any, maxBytes any) *WorkflowFetcher_FetchWorkflow_Call { | |
| 90 | + | return &WorkflowFetcher_FetchWorkflow_Call{Call: _e.mock.On("FetchWorkflow", ctx, owner, repo, sha, path, maxBytes)} | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *WorkflowFetcher_FetchWorkflow_Call) Run(run func(ctx context.Context, owner string, repo string, sha string, path string, maxBytes int64)) *WorkflowFetcher_FetchWorkflow_Call { | |
| 94 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 95 | + | var arg0 context.Context | |
| 96 | + | if args[0] != nil { | |
| 97 | + | arg0 = args[0].(context.Context) | |
| 98 | + | } | |
| 99 | + | var arg1 string | |
| 100 | + | if args[1] != nil { | |
| 101 | + | arg1 = args[1].(string) | |
| 102 | + | } | |
| 103 | + | var arg2 string | |
| 104 | + | if args[2] != nil { | |
| 105 | + | arg2 = args[2].(string) | |
| 106 | + | } | |
| 107 | + | var arg3 string | |
| 108 | + | if args[3] != nil { | |
| 109 | + | arg3 = args[3].(string) | |
| 110 | + | } | |
| 111 | + | var arg4 string | |
| 112 | + | if args[4] != nil { | |
| 113 | + | arg4 = args[4].(string) | |
| 114 | + | } | |
| 115 | + | var arg5 int64 | |
| 116 | + | if args[5] != nil { | |
| 117 | + | arg5 = args[5].(int64) | |
| 118 | + | } | |
| 119 | + | run( | |
| 120 | + | arg0, | |
| 121 | + | arg1, | |
| 122 | + | arg2, | |
| 123 | + | arg3, | |
| 124 | + | arg4, | |
| 125 | + | arg5, | |
| 126 | + | ) | |
| 127 | + | }) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (_c *WorkflowFetcher_FetchWorkflow_Call) Return(bytes []byte, err error) *WorkflowFetcher_FetchWorkflow_Call { | |
| 132 | + | _c.Call.Return(bytes, err) | |
| 133 | + | return _c | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (_c *WorkflowFetcher_FetchWorkflow_Call) RunAndReturn(run func(ctx context.Context, owner string, repo string, sha string, path string, maxBytes int64) ([]byte, error)) *WorkflowFetcher_FetchWorkflow_Call { | |
| 137 | + | _c.Call.Return(run) | |
| 138 | + | return _c | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewWorkflowFetcher creates a new instance of WorkflowFetcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewWorkflowFetcher(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *WorkflowFetcher { | |||
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | + | helper.Helper() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | mock := &WorkflowFetcher{} | |||
| 24 | + | mock.Mock.Test(t) | |||
| 25 | + | ||||
| 26 | + | t.Cleanup(func() { | |||
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 28 | + | helper.Helper() | |||
| 29 | + | } | |||
| 30 | + | mock.AssertExpectations(t) | |||
| 31 | + | }) | |||
| 32 | + | ||||
| 33 | + | return mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // WorkflowFetcher is an autogenerated mock type for the WorkflowFetcher type | |||
| 37 | + | type WorkflowFetcher struct { | |||
| 38 | + | mock.Mock | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type WorkflowFetcher_Expecter struct { | |||
| 42 | + | mock *mock.Mock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (_m *WorkflowFetcher) EXPECT() *WorkflowFetcher_Expecter { | |||
| 46 | + | return &WorkflowFetcher_Expecter{mock: &_m.Mock} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // FetchWorkflow provides a mock function for the type WorkflowFetcher | |||
| 50 | + | func (_mock *WorkflowFetcher) FetchWorkflow(ctx context.Context, owner string, repo string, sha string, path string, maxBytes int64) ([]byte, error) { | |||
| 51 | + | ret := _mock.Called(ctx, owner, repo, sha, path, maxBytes) | |||
| 52 | + | ||||
| 53 | + | if len(ret) == 0 { | |||
| 54 | + | panic("no return value specified for FetchWorkflow") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var r0 []byte | |||
| 58 | + | var r1 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string, int64) ([]byte, error)); ok { | |||
| 60 | + | return returnFunc(ctx, owner, repo, sha, path, maxBytes) | |||
| 61 | + | } | |||
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string, int64) []byte); ok { | |||
| 63 | + | r0 = returnFunc(ctx, owner, repo, sha, path, maxBytes) | |||
| 64 | + | } else { | |||
| 65 | + | if ret.Get(0) != nil { | |||
| 66 | + | r0 = ret.Get(0).([]byte) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, string, int64) error); ok { | |||
| 70 | + | r1 = returnFunc(ctx, owner, repo, sha, path, maxBytes) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // WorkflowFetcher_FetchWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchWorkflow' | |||
| 78 | + | type WorkflowFetcher_FetchWorkflow_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // FetchWorkflow is a helper method to define mock.On call | |||
| 83 | + | // - ctx context.Context | |||
| 84 | + | // - owner string | |||
| 85 | + | // - repo string | |||
| 86 | + | // - sha string | |||
| 87 | + | // - path string | |||
| 88 | + | // - maxBytes int64 | |||
| 89 | + | func (_e *WorkflowFetcher_Expecter) FetchWorkflow(ctx any, owner any, repo any, sha any, path any, maxBytes any) *WorkflowFetcher_FetchWorkflow_Call { | |||
| 90 | + | return &WorkflowFetcher_FetchWorkflow_Call{Call: _e.mock.On("FetchWorkflow", ctx, owner, repo, sha, path, maxBytes)} | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (_c *WorkflowFetcher_FetchWorkflow_Call) Run(run func(ctx context.Context, owner string, repo string, sha string, path string, maxBytes int64)) *WorkflowFetcher_FetchWorkflow_Call { | |||
| 94 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 95 | + | var arg0 context.Context | |||
| 96 | + | if args[0] != nil { | |||
| 97 | + | arg0 = args[0].(context.Context) | |||
| 98 | + | } | |||
| 99 | + | var arg1 string | |||
| 100 | + | if args[1] != nil { | |||
| 101 | + | arg1 = args[1].(string) | |||
| 102 | + | } | |||
| 103 | + | var arg2 string | |||
| 104 | + | if args[2] != nil { | |||
| 105 | + | arg2 = args[2].(string) | |||
| 106 | + | } | |||
| 107 | + | var arg3 string | |||
| 108 | + | if args[3] != nil { | |||
| 109 | + | arg3 = args[3].(string) | |||
| 110 | + | } | |||
| 111 | + | var arg4 string | |||
| 112 | + | if args[4] != nil { | |||
| 113 | + | arg4 = args[4].(string) | |||
| 114 | + | } | |||
| 115 | + | var arg5 int64 | |||
| 116 | + | if args[5] != nil { | |||
| 117 | + | arg5 = args[5].(int64) | |||
| 118 | + | } | |||
| 119 | + | run( | |||
| 120 | + | arg0, | |||
| 121 | + | arg1, | |||
| 122 | + | arg2, | |||
| 123 | + | arg3, | |||
| 124 | + | arg4, | |||
| 125 | + | arg5, | |||
| 126 | + | ) | |||
| 127 | + | }) | |||
| 128 | + | return _c | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (_c *WorkflowFetcher_FetchWorkflow_Call) Return(bytes []byte, err error) *WorkflowFetcher_FetchWorkflow_Call { | |||
| 132 | + | _c.Call.Return(bytes, err) | |||
| 133 | + | return _c | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (_c *WorkflowFetcher_FetchWorkflow_Call) RunAndReturn(run func(ctx context.Context, owner string, repo string, sha string, path string, maxBytes int64) ([]byte, error)) *WorkflowFetcher_FetchWorkflow_Call { | |||
| 137 | + | _c.Call.Return(run) | |||
| 138 | + | return _c | |||
| 139 | + | } | |||
M internal/usecase/ci.go +3 -0
| @@ -69,6 +69,9 @@ type CIConfig struct { | |||
| 69 | 69 | // Remote lists the refs of github.com repositories for ResolveActions; nil | |
| 70 | 70 | // answers every github.com action with unavailable. | |
| 71 | 71 | Remote ports.GitRemote | |
| 72 | + | // Workflows downloads reusable workflow files from github.com at a resolved | |
| 73 | + | // commit; nil fails such a call as unavailable. | |
| 74 | + | Workflows ports.WorkflowFetcher | |
| 72 | 75 | // Archive serves the tarballs of the REST subset. | |
| 73 | 76 | Archive ports.GitArchive | |
| 74 | 77 | // Tokens finds the stored token of the pusher of a run, to ask what it may | |
| @@ -69,6 +69,9 @@ type CIConfig struct { | |||||
| 69 | // Remote lists the refs of github.com repositories for ResolveActions; nil | 69 | // Remote lists the refs of github.com repositories for ResolveActions; nil | ||
| 70 | // answers every github.com action with unavailable. | 70 | // answers every github.com action with unavailable. | ||
| 71 | Remote ports.GitRemote | 71 | Remote ports.GitRemote | ||
| 72 | + | // Workflows downloads reusable workflow files from github.com at a resolved | |||
| 73 | + | // commit; nil fails such a call as unavailable. | |||
| 74 | + | Workflows ports.WorkflowFetcher | |||
| 72 | // Archive serves the tarballs of the REST subset. | 75 | // Archive serves the tarballs of the REST subset. | ||
| 73 | Archive ports.GitArchive | 76 | Archive ports.GitArchive | ||
| 74 | // Tokens finds the stored token of the pusher of a run, to ask what it may | 77 | // Tokens finds the stored token of the pusher of a run, to ask what it may | ||
M internal/usecase/ci_concurrency.go +25 -7
| @@ -313,7 +313,7 @@ func (c *CI) cancelLater(group string, e domain.CIConcurrencyEntry) { | |||
| 313 | 313 | // where it stands: its own record does not say want yet (a promotion whose | |
| 314 | 314 | // planning failed or was lost, or a crash between the write of the group and the | |
| 315 | 315 | // write of the entry). An entry that is gone or over is the pruning's business. | |
| 316 | - | func (c *CI) entryNeeds(ctx context.Context, repo domain.RepoName, e domain.CIConcurrencyEntry, want domain.CIConcurrencyState) bool { | |
| 316 | + | func (c *CI) entryNeeds(ctx context.Context, repo domain.RepoName, group string, e domain.CIConcurrencyEntry, want domain.CIConcurrencyState) bool { | |
| 317 | 317 | if e.JobID == "" { | |
| 318 | 318 | run, err := c.cfg.Store.GetRun(ctx, repo, e.RunID) | |
| 319 | 319 | return err == nil && run.Status != domain.CICompleted && run.Attempt == e.Attempt && run.Concurrency.State != want | |
| @@ -325,7 +325,16 @@ func (c *CI) entryNeeds(ctx context.Context, repo domain.RepoName, e domain.CICo | |||
| 325 | 325 | run, err := c.cfg.Store.GetRun(ctx, repo, e.RunID) | |
| 326 | 326 | return err == nil && run.Status != domain.CICompleted && run.Attempt == e.Attempt | |
| 327 | 327 | } | |
| 328 | - | return err == nil && !j.Ended() && j.Phase == domain.CIPhaseWaiting && j.Concurrency.State != want | |
| 328 | + | if err != nil || j.Ended() || j.Phase != domain.CIPhaseWaiting { | |
| 329 | + | return false | |
| 330 | + | } | |
| 331 | + | // A call holds two groups, its own and its callee's: the state to compare | |
| 332 | + | // is the one of the group asked about. | |
| 333 | + | if j.Call != nil && domain.ConcurrencyKey(j.Call.Concurrency.Group) == domain.ConcurrencyKey(group) && | |
| 334 | + | domain.ConcurrencyKey(j.Concurrency.Group) != domain.ConcurrencyKey(group) { | |
| 335 | + | return j.Call.Concurrency.State != want | |
| 336 | + | } | |
| 337 | + | return j.Concurrency.State != want | |
| 329 | 338 | } | |
| 330 | 339 | ||
| 331 | 340 | // ReconcileConcurrency repairs the groups. It runs at startup and every minute. | |
| @@ -376,11 +385,11 @@ func (c *CI) ReconcileConcurrency(ctx context.Context) { | |||
| 376 | 385 | if r.Canceling { | |
| 377 | 386 | c.cancelLater(st.Marks[r.Key()].Group, *r) | |
| 378 | 387 | } | |
| 379 | - | if c.entryNeeds(ctx, g.Repo, *r, domain.CIConcurrencyHolding) { | |
| 388 | + | if c.entryNeeds(ctx, g.Repo, g.Group, *r, domain.CIConcurrencyHolding) { | |
| 380 | 389 | c.wake(g.Repo, g.Group, *r) | |
| 381 | 390 | } | |
| 382 | 391 | } | |
| 383 | - | if p := st.Record.Pending; p != nil && c.entryNeeds(ctx, g.Repo, *p, domain.CIConcurrencyPending) { | |
| 392 | + | if p := st.Record.Pending; p != nil && c.entryNeeds(ctx, g.Repo, g.Group, *p, domain.CIConcurrencyPending) { | |
| 384 | 393 | c.wake(g.Repo, g.Group, *p) | |
| 385 | 394 | } | |
| 386 | 395 | for _, m := range st.Marks { | |
| @@ -618,12 +627,21 @@ func (rs *runState) releaseEnded(ctx context.Context, changed []int, runDone boo | |||
| 618 | 627 | c := rs.c | |
| 619 | 628 | for _, i := range changed { | |
| 620 | 629 | j := rs.jobs[i] | |
| 621 | - | if !j.Ended() || j.Concurrency.Group == "" || j.Concurrency.FromRun { | |
| 630 | + | if !j.Ended() { | |
| 622 | 631 | continue | |
| 623 | 632 | } | |
| 624 | 633 | e := domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID} | |
| 625 | - | if err := c.releaseEntry(ctx, rs.run.Repo, j.Concurrency.Group, e); err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 626 | - | ctxLog(ctx).Error().Err(err).Str("job_id", j.ID).Msg("ci: free a concurrency group") | |
| 634 | + | groups := []string{} | |
| 635 | + | if j.Concurrency.Group != "" && !j.Concurrency.FromRun { | |
| 636 | + | groups = append(groups, j.Concurrency.Group) | |
| 637 | + | } | |
| 638 | + | if j.Call != nil && j.Call.Concurrency.Group != "" { | |
| 639 | + | groups = append(groups, j.Call.Concurrency.Group) // a call ends holding both | |
| 640 | + | } | |
| 641 | + | for _, g := range groups { | |
| 642 | + | if err := c.releaseEntry(ctx, rs.run.Repo, g, e); err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 643 | + | ctxLog(ctx).Error().Err(err).Str("job_id", j.ID).Msg("ci: free a concurrency group") | |
| 644 | + | } | |
| 627 | 645 | } | |
| 628 | 646 | } | |
| 629 | 647 | if runDone && rs.run.Concurrency.Group != "" { | |
| @@ -313,7 +313,7 @@ func (c *CI) cancelLater(group string, e domain.CIConcurrencyEntry) { | |||||
| 313 | // where it stands: its own record does not say want yet (a promotion whose | 313 | // where it stands: its own record does not say want yet (a promotion whose | ||
| 314 | // planning failed or was lost, or a crash between the write of the group and the | 314 | // planning failed or was lost, or a crash between the write of the group and the | ||
| 315 | // write of the entry). An entry that is gone or over is the pruning's business. | 315 | // write of the entry). An entry that is gone or over is the pruning's business. | ||
| 316 | - | func (c *CI) entryNeeds(ctx context.Context, repo domain.RepoName, e domain.CIConcurrencyEntry, want domain.CIConcurrencyState) bool { | 316 | + | func (c *CI) entryNeeds(ctx context.Context, repo domain.RepoName, group string, e domain.CIConcurrencyEntry, want domain.CIConcurrencyState) bool { |
| 317 | if e.JobID == "" { | 317 | if e.JobID == "" { | ||
| 318 | run, err := c.cfg.Store.GetRun(ctx, repo, e.RunID) | 318 | run, err := c.cfg.Store.GetRun(ctx, repo, e.RunID) | ||
| 319 | return err == nil && run.Status != domain.CICompleted && run.Attempt == e.Attempt && run.Concurrency.State != want | 319 | return err == nil && run.Status != domain.CICompleted && run.Attempt == e.Attempt && run.Concurrency.State != want | ||
| @@ -325,7 +325,16 @@ func (c *CI) entryNeeds(ctx context.Context, repo domain.RepoName, e domain.CICo | |||||
| 325 | run, err := c.cfg.Store.GetRun(ctx, repo, e.RunID) | 325 | run, err := c.cfg.Store.GetRun(ctx, repo, e.RunID) | ||
| 326 | return err == nil && run.Status != domain.CICompleted && run.Attempt == e.Attempt | 326 | return err == nil && run.Status != domain.CICompleted && run.Attempt == e.Attempt | ||
| 327 | } | 327 | } | ||
| 328 | - | return err == nil && !j.Ended() && j.Phase == domain.CIPhaseWaiting && j.Concurrency.State != want | 328 | + | if err != nil || j.Ended() || j.Phase != domain.CIPhaseWaiting { |
| 329 | + | return false | |||
| 330 | + | } | |||
| 331 | + | // A call holds two groups, its own and its callee's: the state to compare | |||
| 332 | + | // is the one of the group asked about. | |||
| 333 | + | if j.Call != nil && domain.ConcurrencyKey(j.Call.Concurrency.Group) == domain.ConcurrencyKey(group) && | |||
| 334 | + | domain.ConcurrencyKey(j.Concurrency.Group) != domain.ConcurrencyKey(group) { | |||
| 335 | + | return j.Call.Concurrency.State != want | |||
| 336 | + | } | |||
| 337 | + | return j.Concurrency.State != want | |||
| 329 | } | 338 | } | ||
| 330 | 339 | ||||
| 331 | // ReconcileConcurrency repairs the groups. It runs at startup and every minute. | 340 | // ReconcileConcurrency repairs the groups. It runs at startup and every minute. | ||
| @@ -376,11 +385,11 @@ func (c *CI) ReconcileConcurrency(ctx context.Context) { | |||||
| 376 | if r.Canceling { | 385 | if r.Canceling { | ||
| 377 | c.cancelLater(st.Marks[r.Key()].Group, *r) | 386 | c.cancelLater(st.Marks[r.Key()].Group, *r) | ||
| 378 | } | 387 | } | ||
| 379 | - | if c.entryNeeds(ctx, g.Repo, *r, domain.CIConcurrencyHolding) { | 388 | + | if c.entryNeeds(ctx, g.Repo, g.Group, *r, domain.CIConcurrencyHolding) { |
| 380 | c.wake(g.Repo, g.Group, *r) | 389 | c.wake(g.Repo, g.Group, *r) | ||
| 381 | } | 390 | } | ||
| 382 | } | 391 | } | ||
| 383 | - | if p := st.Record.Pending; p != nil && c.entryNeeds(ctx, g.Repo, *p, domain.CIConcurrencyPending) { | 392 | + | if p := st.Record.Pending; p != nil && c.entryNeeds(ctx, g.Repo, g.Group, *p, domain.CIConcurrencyPending) { |
| 384 | c.wake(g.Repo, g.Group, *p) | 393 | c.wake(g.Repo, g.Group, *p) | ||
| 385 | } | 394 | } | ||
| 386 | for _, m := range st.Marks { | 395 | for _, m := range st.Marks { | ||
| @@ -618,12 +627,21 @@ func (rs *runState) releaseEnded(ctx context.Context, changed []int, runDone boo | |||||
| 618 | c := rs.c | 627 | c := rs.c | ||
| 619 | for _, i := range changed { | 628 | for _, i := range changed { | ||
| 620 | j := rs.jobs[i] | 629 | j := rs.jobs[i] | ||
| 621 | - | if !j.Ended() || j.Concurrency.Group == "" || j.Concurrency.FromRun { | 630 | + | if !j.Ended() { |
| 622 | continue | 631 | continue | ||
| 623 | } | 632 | } | ||
| 624 | e := domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID} | 633 | e := domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID} | ||
| 625 | - | if err := c.releaseEntry(ctx, rs.run.Repo, j.Concurrency.Group, e); err != nil && !errors.Is(err, domain.ErrNotFound) { | 634 | + | groups := []string{} |
| 626 | - | ctxLog(ctx).Error().Err(err).Str("job_id", j.ID).Msg("ci: free a concurrency group") | 635 | + | if j.Concurrency.Group != "" && !j.Concurrency.FromRun { |
| 636 | + | groups = append(groups, j.Concurrency.Group) | |||
| 637 | + | } | |||
| 638 | + | if j.Call != nil && j.Call.Concurrency.Group != "" { | |||
| 639 | + | groups = append(groups, j.Call.Concurrency.Group) // a call ends holding both | |||
| 640 | + | } | |||
| 641 | + | for _, g := range groups { | |||
| 642 | + | if err := c.releaseEntry(ctx, rs.run.Repo, g, e); err != nil && !errors.Is(err, domain.ErrNotFound) { | |||
| 643 | + | ctxLog(ctx).Error().Err(err).Str("job_id", j.ID).Msg("ci: free a concurrency group") | |||
| 644 | + | } | |||
| 627 | } | 645 | } | ||
| 628 | } | 646 | } | ||
| 629 | if runDone && rs.run.Concurrency.Group != "" { | 647 | if runDone && rs.run.Concurrency.Group != "" { | ||
M internal/usecase/ci_plan.go +51 -31
| @@ -32,10 +32,12 @@ type wfCache struct { | |||
| 32 | 32 | // never a value), per run then job key: the workflow file of a run does not | |
| 33 | 33 | // change, so the walk of its YAML is done once per job. | |
| 34 | 34 | uses map[int64]map[string]secretUse | |
| 35 | + | // called keeps the parsed reusable workflows, by hash of path and content. | |
| 36 | + | called map[string]*workflow.Workflow | |
| 35 | 37 | } | |
| 36 | 38 | ||
| 37 | 39 | func newWfCache(max int) *wfCache { | |
| 38 | - | return &wfCache{max: max, m: map[int64]*workflow.Workflow{}, uses: map[int64]map[string]secretUse{}} | |
| 40 | + | return &wfCache{max: max, m: map[int64]*workflow.Workflow{}, uses: map[int64]map[string]secretUse{}, called: map[string]*workflow.Workflow{}} | |
| 39 | 41 | } | |
| 40 | 42 | ||
| 41 | 43 | // secretUse returns the secrets the job refers to, walking the YAML once per | |
| @@ -80,19 +82,6 @@ func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error | |||
| 80 | 82 | return wf, nil | |
| 81 | 83 | } | |
| 82 | 84 | ||
| 83 | - | // unsupported names the feature of a job that ermite does not run yet, or "". | |
| 84 | - | // Such a job fails with "not supported yet: <feature>" instead of running | |
| 85 | - | // wrong. CI-2 runs steps with uses:, job container:, services: and step | |
| 86 | - | // timeout-minutes (ADR 0025), CI-3 runs environment:; what stays is the rest of | |
| 87 | - | // CI-3. | |
| 88 | - | func unsupported(j *workflow.Job) string { | |
| 89 | - | switch { | |
| 90 | - | case j.IsReusable(): | |
| 91 | - | return "reusable workflows (uses at the job level)" | |
| 92 | - | } | |
| 93 | - | return "" | |
| 94 | - | } | |
| 95 | - | ||
| 96 | 85 | // instances returns the indexes of the records of the job key, in order. | |
| 97 | 86 | func (rs *runState) instances(key string) []int { | |
| 98 | 87 | var out []int | |
| @@ -153,12 +142,12 @@ func result(insts []domain.CIJob) string { | |||
| 153 | 142 | // ancestor failed (documented, not recorded). An ancestor beyond the direct | |
| 154 | 143 | // needs only changes success() and failure(): cancelled() is not recorded for | |
| 155 | 144 | // them, and a skipped ancestor behind a need that did run is not either. | |
| 156 | - | func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) { | |
| 145 | + | func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job, scope string) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) { | |
| 157 | 146 | needs = expr.NewDictionary() | |
| 158 | 147 | st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested} | |
| 159 | 148 | for _, n := range wf.Ancestors(j.ID) { | |
| 160 | 149 | var insts []domain.CIJob | |
| 161 | - | for _, i := range rs.instances(n) { | |
| 150 | + | for _, i := range rs.instancesIn(scope, n) { | |
| 162 | 151 | if rs.jobs[i].Ended() { | |
| 163 | 152 | insts = append(insts, rs.jobs[i]) | |
| 164 | 153 | } | |
| @@ -172,7 +161,7 @@ func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job) (needs *expr | |||
| 172 | 161 | } | |
| 173 | 162 | for _, n := range j.Needs { | |
| 174 | 163 | var insts []domain.CIJob | |
| 175 | - | for _, i := range rs.instances(n) { | |
| 164 | + | for _, i := range rs.instancesIn(scope, n) { | |
| 176 | 165 | if !rs.jobs[i].Ended() { | |
| 177 | 166 | return nil, st, false | |
| 178 | 167 | } | |
| @@ -275,7 +264,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str | |||
| 275 | 264 | if j.FailFast && !j.ContinueOnError && conclusion.IsFailure() { | |
| 276 | 265 | for i := range rs.jobs { | |
| 277 | 266 | s := &rs.jobs[i] | |
| 278 | - | if s.Key != j.Key || s.ID == j.ID || s.Ended() { | |
| 267 | + | if s.Key != j.Key || s.Scope != j.Scope || s.ID == j.ID || s.Ended() { | |
| 279 | 268 | continue | |
| 280 | 269 | } | |
| 281 | 270 | if s.Active() { | |
| @@ -344,13 +333,21 @@ func (rs *runState) advance(ctx context.Context) error { | |||
| 344 | 333 | if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting { | |
| 345 | 334 | continue | |
| 346 | 335 | } | |
| 347 | - | needs, st, ready := rs.needsOf(wf, wj) | |
| 336 | + | needs, st, ready := rs.needsOf(wf, wj, "") | |
| 348 | 337 | if !ready { | |
| 349 | 338 | continue | |
| 350 | 339 | } | |
| 351 | 340 | rs.planJob(ctx, wf, wj, insts[0], needs, st) | |
| 352 | 341 | changed = true | |
| 353 | 342 | } | |
| 343 | + | // The jobs of called workflows, and the calls that have ended. | |
| 344 | + | ch, err := rs.advanceCalls(ctx) | |
| 345 | + | if err != nil { | |
| 346 | + | return err | |
| 347 | + | } | |
| 348 | + | if ch { | |
| 349 | + | changed = true | |
| 350 | + | } | |
| 354 | 351 | } | |
| 355 | 352 | return rs.promote(ctx) | |
| 356 | 353 | } | |
| @@ -363,15 +360,15 @@ func (rs *runState) promote(ctx context.Context) error { | |||
| 363 | 360 | counts := map[string]int{} | |
| 364 | 361 | for _, j := range rs.jobs { | |
| 365 | 362 | if j.Phase == domain.CIPhaseReady || j.Active() { | |
| 366 | - | counts[j.Key]++ | |
| 363 | + | counts[j.Scope+"\x00"+j.Key]++ | |
| 367 | 364 | } | |
| 368 | 365 | } | |
| 369 | 366 | for i := range rs.jobs { | |
| 370 | 367 | j := &rs.jobs[i] | |
| 371 | - | if !j.Planned || j.Phase != domain.CIPhaseWaiting { | |
| 372 | - | continue | |
| 368 | + | if !j.Planned || j.Phase != domain.CIPhaseWaiting || j.Call != nil { | |
| 369 | + | continue // a call never runs: it ends with the jobs of its scope | |
| 373 | 370 | } | |
| 374 | - | if j.MaxParallel > 0 && counts[j.Key] >= j.MaxParallel { | |
| 371 | + | if j.MaxParallel > 0 && counts[j.Scope+"\x00"+j.Key] >= j.MaxParallel { | |
| 375 | 372 | continue | |
| 376 | 373 | } | |
| 377 | 374 | if g := j.Concurrency; g.Group != "" && !g.FromRun { | |
| @@ -388,7 +385,7 @@ func (rs *runState) promote(ctx context.Context) error { | |||
| 388 | 385 | } | |
| 389 | 386 | } | |
| 390 | 387 | j.Phase, j.ReadyAt = domain.CIPhaseReady, now | |
| 391 | - | counts[j.Key]++ | |
| 388 | + | counts[j.Scope+"\x00"+j.Key]++ | |
| 392 | 389 | rs.touch(j) | |
| 393 | 390 | } | |
| 394 | 391 | return nil | |
| @@ -415,7 +412,7 @@ func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *work | |||
| 415 | 412 | return | |
| 416 | 413 | } | |
| 417 | 414 | env := template.Env{Contexts: map[string]expr.Value{ | |
| 418 | - | "github": gh, "needs": needs, "inputs": expr.NewDictionary(), "vars": vars, | |
| 415 | + | "github": gh, "needs": needs, "inputs": rs.inputsOf(rec), "vars": vars, | |
| 419 | 416 | }} | |
| 420 | 417 | run, err := wj.EvaluateIf(ctx, env, st) | |
| 421 | 418 | switch { | |
| @@ -424,11 +421,20 @@ func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *work | |||
| 424 | 421 | return | |
| 425 | 422 | case !run: | |
| 426 | 423 | rs.jobs[idx].Name = rs.skippedName(ctx, wj, env) | |
| 424 | + | if call := rs.scopeOf(rec); call != nil { | |
| 425 | + | rs.jobs[idx].Name = call.Name + " / " + rs.jobs[idx].Name | |
| 426 | + | } | |
| 427 | 427 | rs.end(&rs.jobs[idx], domain.CISkipped, "") | |
| 428 | 428 | return | |
| 429 | 429 | } | |
| 430 | - | if feature := unsupported(wj); feature != "" { | |
| 431 | - | fail("not supported yet: " + feature) | |
| 430 | + | if wj.IsReusable() { | |
| 431 | + | if err := rs.planCall(ctx, wf, wj, idx, env); err != nil { | |
| 432 | + | if text, ok := isCallFailure(err); ok { | |
| 433 | + | fail(text) | |
| 434 | + | } else { | |
| 435 | + | fail("reusable workflow " + wj.Uses.Value + ": " + err.Error()) | |
| 436 | + | } | |
| 437 | + | } | |
| 432 | 438 | return | |
| 433 | 439 | } | |
| 434 | 440 | plan, err := wj.EvaluateStrategy(ctx, env) | |
| @@ -437,6 +443,10 @@ func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *work | |||
| 437 | 443 | return | |
| 438 | 444 | } | |
| 439 | 445 | ||
| 446 | + | if err := rs.checkJobBudget(len(plan.Combos) - 1); err != nil { | |
| 447 | + | fail(err.Error()) | |
| 448 | + | return | |
| 449 | + | } | |
| 440 | 450 | insts := make([]domain.CIJob, 0, len(plan.Combos)) | |
| 441 | 451 | for i := range plan.Combos { | |
| 442 | 452 | menv, err := plan.MatrixEnv(env, i) | |
| @@ -484,6 +494,10 @@ func (rs *runState) instance(ctx context.Context, wf *workflow.Workflow, wj *wor | |||
| 484 | 494 | return j, err | |
| 485 | 495 | } | |
| 486 | 496 | name = strings.TrimSpace(name) // GitHub shows "Display name of", not "Display name of " | |
| 497 | + | call := rs.scopeOf(base) | |
| 498 | + | if call != nil { | |
| 499 | + | name = call.Name + " / " + name // "<caller job name> / <callee job name>" | |
| 500 | + | } | |
| 487 | 501 | labels, _, err := wj.EvaluateRunsOn(ctx, env) | |
| 488 | 502 | if err != nil { | |
| 489 | 503 | return j, err | |
| @@ -517,12 +531,18 @@ func (rs *runState) instance(ctx context.Context, wf *workflow.Workflow, wj *wor | |||
| 517 | 531 | if vars == nil { | |
| 518 | 532 | vars = expr.NewDictionary() | |
| 519 | 533 | } | |
| 520 | - | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, vars, rs.c.jobKeys(ctx, rs.run, base.Index, i)) | |
| 534 | + | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, vars, rs.inputsOf(base), rs.jobKeysOf(ctx, base, i)) | |
| 521 | 535 | if err != nil { | |
| 522 | 536 | return j, err | |
| 523 | 537 | } | |
| 524 | 538 | j.Environment = environment | |
| 525 | - | j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |
| 539 | + | if call != nil { | |
| 540 | + | if j.Permissions, err = rs.scopedPermissions(wf, wj, call); err != nil { | |
| 541 | + | return j, err | |
| 542 | + | } | |
| 543 | + | } else { | |
| 544 | + | j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |
| 545 | + | } | |
| 526 | 546 | j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout | |
| 527 | 547 | j.MatrixJSON = compactJSON(expr.ToJSON(matrix)) | |
| 528 | 548 | j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos) | |
| @@ -565,11 +585,11 @@ func compactJSON(s string) string { | |||
| 565 | 585 | // workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the | |
| 566 | 586 | // runner adds status. The runner copies the keys of this "job" into its job | |
| 567 | 587 | // context (executor.State.initRest), whatever they are. | |
| 568 | - | func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, vars *expr.Dictionary, job ordered) (string, error) { | |
| 588 | + | func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, vars, inputs *expr.Dictionary, job ordered) (string, error) { | |
| 569 | 589 | out := map[string]json.RawMessage{} | |
| 570 | 590 | for k, v := range map[string]expr.Value{ | |
| 571 | 591 | "github": github, "needs": needs, "strategy": strategy, "matrix": matrix, | |
| 572 | - | "inputs": expr.NewDictionary(), "vars": vars, | |
| 592 | + | "inputs": inputs, "vars": vars, | |
| 573 | 593 | } { | |
| 574 | 594 | out[k] = json.RawMessage(compactJSON(expr.ToJSON(v))) | |
| 575 | 595 | } | |
| @@ -32,10 +32,12 @@ type wfCache struct { | |||||
| 32 | // never a value), per run then job key: the workflow file of a run does not | 32 | // never a value), per run then job key: the workflow file of a run does not | ||
| 33 | // change, so the walk of its YAML is done once per job. | 33 | // change, so the walk of its YAML is done once per job. | ||
| 34 | uses map[int64]map[string]secretUse | 34 | uses map[int64]map[string]secretUse | ||
| 35 | + | // called keeps the parsed reusable workflows, by hash of path and content. | |||
| 36 | + | called map[string]*workflow.Workflow | |||
| 35 | } | 37 | } | ||
| 36 | 38 | ||||
| 37 | func newWfCache(max int) *wfCache { | 39 | func newWfCache(max int) *wfCache { | ||
| 38 | - | return &wfCache{max: max, m: map[int64]*workflow.Workflow{}, uses: map[int64]map[string]secretUse{}} | 40 | + | return &wfCache{max: max, m: map[int64]*workflow.Workflow{}, uses: map[int64]map[string]secretUse{}, called: map[string]*workflow.Workflow{}} |
| 39 | } | 41 | } | ||
| 40 | 42 | ||||
| 41 | // secretUse returns the secrets the job refers to, walking the YAML once per | 43 | // secretUse returns the secrets the job refers to, walking the YAML once per | ||
| @@ -80,19 +82,6 @@ func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error | |||||
| 80 | return wf, nil | 82 | return wf, nil | ||
| 81 | } | 83 | } | ||
| 82 | 84 | ||||
| 83 | - | // unsupported names the feature of a job that ermite does not run yet, or "". | |||
| 84 | - | // Such a job fails with "not supported yet: <feature>" instead of running | |||
| 85 | - | // wrong. CI-2 runs steps with uses:, job container:, services: and step | |||
| 86 | - | // timeout-minutes (ADR 0025), CI-3 runs environment:; what stays is the rest of | |||
| 87 | - | // CI-3. | |||
| 88 | - | func unsupported(j *workflow.Job) string { | |||
| 89 | - | switch { | |||
| 90 | - | case j.IsReusable(): | |||
| 91 | - | return "reusable workflows (uses at the job level)" | |||
| 92 | - | } | |||
| 93 | - | return "" | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | // instances returns the indexes of the records of the job key, in order. | 85 | // instances returns the indexes of the records of the job key, in order. | ||
| 97 | func (rs *runState) instances(key string) []int { | 86 | func (rs *runState) instances(key string) []int { | ||
| 98 | var out []int | 87 | var out []int | ||
| @@ -153,12 +142,12 @@ func result(insts []domain.CIJob) string { | |||||
| 153 | // ancestor failed (documented, not recorded). An ancestor beyond the direct | 142 | // ancestor failed (documented, not recorded). An ancestor beyond the direct | ||
| 154 | // needs only changes success() and failure(): cancelled() is not recorded for | 143 | // needs only changes success() and failure(): cancelled() is not recorded for | ||
| 155 | // them, and a skipped ancestor behind a need that did run is not either. | 144 | // them, and a skipped ancestor behind a need that did run is not either. | ||
| 156 | - | func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) { | 145 | + | func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job, scope string) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) { |
| 157 | needs = expr.NewDictionary() | 146 | needs = expr.NewDictionary() | ||
| 158 | st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested} | 147 | st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested} | ||
| 159 | for _, n := range wf.Ancestors(j.ID) { | 148 | for _, n := range wf.Ancestors(j.ID) { | ||
| 160 | var insts []domain.CIJob | 149 | var insts []domain.CIJob | ||
| 161 | - | for _, i := range rs.instances(n) { | 150 | + | for _, i := range rs.instancesIn(scope, n) { |
| 162 | if rs.jobs[i].Ended() { | 151 | if rs.jobs[i].Ended() { | ||
| 163 | insts = append(insts, rs.jobs[i]) | 152 | insts = append(insts, rs.jobs[i]) | ||
| 164 | } | 153 | } | ||
| @@ -172,7 +161,7 @@ func (rs *runState) needsOf(wf *workflow.Workflow, j *workflow.Job) (needs *expr | |||||
| 172 | } | 161 | } | ||
| 173 | for _, n := range j.Needs { | 162 | for _, n := range j.Needs { | ||
| 174 | var insts []domain.CIJob | 163 | var insts []domain.CIJob | ||
| 175 | - | for _, i := range rs.instances(n) { | 164 | + | for _, i := range rs.instancesIn(scope, n) { |
| 176 | if !rs.jobs[i].Ended() { | 165 | if !rs.jobs[i].Ended() { | ||
| 177 | return nil, st, false | 166 | return nil, st, false | ||
| 178 | } | 167 | } | ||
| @@ -275,7 +264,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str | |||||
| 275 | if j.FailFast && !j.ContinueOnError && conclusion.IsFailure() { | 264 | if j.FailFast && !j.ContinueOnError && conclusion.IsFailure() { | ||
| 276 | for i := range rs.jobs { | 265 | for i := range rs.jobs { | ||
| 277 | s := &rs.jobs[i] | 266 | s := &rs.jobs[i] | ||
| 278 | - | if s.Key != j.Key || s.ID == j.ID || s.Ended() { | 267 | + | if s.Key != j.Key || s.Scope != j.Scope || s.ID == j.ID || s.Ended() { |
| 279 | continue | 268 | continue | ||
| 280 | } | 269 | } | ||
| 281 | if s.Active() { | 270 | if s.Active() { | ||
| @@ -344,13 +333,21 @@ func (rs *runState) advance(ctx context.Context) error { | |||||
| 344 | if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting { | 333 | if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting { | ||
| 345 | continue | 334 | continue | ||
| 346 | } | 335 | } | ||
| 347 | - | needs, st, ready := rs.needsOf(wf, wj) | 336 | + | needs, st, ready := rs.needsOf(wf, wj, "") |
| 348 | if !ready { | 337 | if !ready { | ||
| 349 | continue | 338 | continue | ||
| 350 | } | 339 | } | ||
| 351 | rs.planJob(ctx, wf, wj, insts[0], needs, st) | 340 | rs.planJob(ctx, wf, wj, insts[0], needs, st) | ||
| 352 | changed = true | 341 | changed = true | ||
| 353 | } | 342 | } | ||
| 343 | + | // The jobs of called workflows, and the calls that have ended. | |||
| 344 | + | ch, err := rs.advanceCalls(ctx) | |||
| 345 | + | if err != nil { | |||
| 346 | + | return err | |||
| 347 | + | } | |||
| 348 | + | if ch { | |||
| 349 | + | changed = true | |||
| 350 | + | } | |||
| 354 | } | 351 | } | ||
| 355 | return rs.promote(ctx) | 352 | return rs.promote(ctx) | ||
| 356 | } | 353 | } | ||
| @@ -363,15 +360,15 @@ func (rs *runState) promote(ctx context.Context) error { | |||||
| 363 | counts := map[string]int{} | 360 | counts := map[string]int{} | ||
| 364 | for _, j := range rs.jobs { | 361 | for _, j := range rs.jobs { | ||
| 365 | if j.Phase == domain.CIPhaseReady || j.Active() { | 362 | if j.Phase == domain.CIPhaseReady || j.Active() { | ||
| 366 | - | counts[j.Key]++ | 363 | + | counts[j.Scope+"\x00"+j.Key]++ |
| 367 | } | 364 | } | ||
| 368 | } | 365 | } | ||
| 369 | for i := range rs.jobs { | 366 | for i := range rs.jobs { | ||
| 370 | j := &rs.jobs[i] | 367 | j := &rs.jobs[i] | ||
| 371 | - | if !j.Planned || j.Phase != domain.CIPhaseWaiting { | 368 | + | if !j.Planned || j.Phase != domain.CIPhaseWaiting || j.Call != nil { |
| 372 | - | continue | 369 | + | continue // a call never runs: it ends with the jobs of its scope |
| 373 | } | 370 | } | ||
| 374 | - | if j.MaxParallel > 0 && counts[j.Key] >= j.MaxParallel { | 371 | + | if j.MaxParallel > 0 && counts[j.Scope+"\x00"+j.Key] >= j.MaxParallel { |
| 375 | continue | 372 | continue | ||
| 376 | } | 373 | } | ||
| 377 | if g := j.Concurrency; g.Group != "" && !g.FromRun { | 374 | if g := j.Concurrency; g.Group != "" && !g.FromRun { | ||
| @@ -388,7 +385,7 @@ func (rs *runState) promote(ctx context.Context) error { | |||||
| 388 | } | 385 | } | ||
| 389 | } | 386 | } | ||
| 390 | j.Phase, j.ReadyAt = domain.CIPhaseReady, now | 387 | j.Phase, j.ReadyAt = domain.CIPhaseReady, now | ||
| 391 | - | counts[j.Key]++ | 388 | + | counts[j.Scope+"\x00"+j.Key]++ |
| 392 | rs.touch(j) | 389 | rs.touch(j) | ||
| 393 | } | 390 | } | ||
| 394 | return nil | 391 | return nil | ||
| @@ -415,7 +412,7 @@ func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *work | |||||
| 415 | return | 412 | return | ||
| 416 | } | 413 | } | ||
| 417 | env := template.Env{Contexts: map[string]expr.Value{ | 414 | env := template.Env{Contexts: map[string]expr.Value{ | ||
| 418 | - | "github": gh, "needs": needs, "inputs": expr.NewDictionary(), "vars": vars, | 415 | + | "github": gh, "needs": needs, "inputs": rs.inputsOf(rec), "vars": vars, |
| 419 | }} | 416 | }} | ||
| 420 | run, err := wj.EvaluateIf(ctx, env, st) | 417 | run, err := wj.EvaluateIf(ctx, env, st) | ||
| 421 | switch { | 418 | switch { | ||
| @@ -424,11 +421,20 @@ func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *work | |||||
| 424 | return | 421 | return | ||
| 425 | case !run: | 422 | case !run: | ||
| 426 | rs.jobs[idx].Name = rs.skippedName(ctx, wj, env) | 423 | rs.jobs[idx].Name = rs.skippedName(ctx, wj, env) | ||
| 424 | + | if call := rs.scopeOf(rec); call != nil { | |||
| 425 | + | rs.jobs[idx].Name = call.Name + " / " + rs.jobs[idx].Name | |||
| 426 | + | } | |||
| 427 | rs.end(&rs.jobs[idx], domain.CISkipped, "") | 427 | rs.end(&rs.jobs[idx], domain.CISkipped, "") | ||
| 428 | return | 428 | return | ||
| 429 | } | 429 | } | ||
| 430 | - | if feature := unsupported(wj); feature != "" { | 430 | + | if wj.IsReusable() { |
| 431 | - | fail("not supported yet: " + feature) | 431 | + | if err := rs.planCall(ctx, wf, wj, idx, env); err != nil { |
| 432 | + | if text, ok := isCallFailure(err); ok { | |||
| 433 | + | fail(text) | |||
| 434 | + | } else { | |||
| 435 | + | fail("reusable workflow " + wj.Uses.Value + ": " + err.Error()) | |||
| 436 | + | } | |||
| 437 | + | } | |||
| 432 | return | 438 | return | ||
| 433 | } | 439 | } | ||
| 434 | plan, err := wj.EvaluateStrategy(ctx, env) | 440 | plan, err := wj.EvaluateStrategy(ctx, env) | ||
| @@ -437,6 +443,10 @@ func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *work | |||||
| 437 | return | 443 | return | ||
| 438 | } | 444 | } | ||
| 439 | 445 | ||||
| 446 | + | if err := rs.checkJobBudget(len(plan.Combos) - 1); err != nil { | |||
| 447 | + | fail(err.Error()) | |||
| 448 | + | return | |||
| 449 | + | } | |||
| 440 | insts := make([]domain.CIJob, 0, len(plan.Combos)) | 450 | insts := make([]domain.CIJob, 0, len(plan.Combos)) | ||
| 441 | for i := range plan.Combos { | 451 | for i := range plan.Combos { | ||
| 442 | menv, err := plan.MatrixEnv(env, i) | 452 | menv, err := plan.MatrixEnv(env, i) | ||
| @@ -484,6 +494,10 @@ func (rs *runState) instance(ctx context.Context, wf *workflow.Workflow, wj *wor | |||||
| 484 | return j, err | 494 | return j, err | ||
| 485 | } | 495 | } | ||
| 486 | name = strings.TrimSpace(name) // GitHub shows "Display name of", not "Display name of " | 496 | name = strings.TrimSpace(name) // GitHub shows "Display name of", not "Display name of " | ||
| 497 | + | call := rs.scopeOf(base) | |||
| 498 | + | if call != nil { | |||
| 499 | + | name = call.Name + " / " + name // "<caller job name> / <callee job name>" | |||
| 500 | + | } | |||
| 487 | labels, _, err := wj.EvaluateRunsOn(ctx, env) | 501 | labels, _, err := wj.EvaluateRunsOn(ctx, env) | ||
| 488 | if err != nil { | 502 | if err != nil { | ||
| 489 | return j, err | 503 | return j, err | ||
| @@ -517,12 +531,18 @@ func (rs *runState) instance(ctx context.Context, wf *workflow.Workflow, wj *wor | |||||
| 517 | if vars == nil { | 531 | if vars == nil { | ||
| 518 | vars = expr.NewDictionary() | 532 | vars = expr.NewDictionary() | ||
| 519 | } | 533 | } | ||
| 520 | - | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, vars, rs.c.jobKeys(ctx, rs.run, base.Index, i)) | 534 | + | contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, vars, rs.inputsOf(base), rs.jobKeysOf(ctx, base, i)) |
| 521 | if err != nil { | 535 | if err != nil { | ||
| 522 | return j, err | 536 | return j, err | ||
| 523 | } | 537 | } | ||
| 524 | j.Environment = environment | 538 | j.Environment = environment | ||
| 525 | - | j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | 539 | + | if call != nil { |
| 540 | + | if j.Permissions, err = rs.scopedPermissions(wf, wj, call); err != nil { | |||
| 541 | + | return j, err | |||
| 542 | + | } | |||
| 543 | + | } else { | |||
| 544 | + | j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |||
| 545 | + | } | |||
| 526 | j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout | 546 | j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout | ||
| 527 | j.MatrixJSON = compactJSON(expr.ToJSON(matrix)) | 547 | j.MatrixJSON = compactJSON(expr.ToJSON(matrix)) | ||
| 528 | j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos) | 548 | j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos) | ||
| @@ -565,11 +585,11 @@ func compactJSON(s string) string { | |||||
| 565 | // workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the | 585 | // workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the | ||
| 566 | // runner adds status. The runner copies the keys of this "job" into its job | 586 | // runner adds status. The runner copies the keys of this "job" into its job | ||
| 567 | // context (executor.State.initRest), whatever they are. | 587 | // context (executor.State.initRest), whatever they are. | ||
| 568 | - | func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, vars *expr.Dictionary, job ordered) (string, error) { | 588 | + | func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, vars, inputs *expr.Dictionary, job ordered) (string, error) { |
| 569 | out := map[string]json.RawMessage{} | 589 | out := map[string]json.RawMessage{} | ||
| 570 | for k, v := range map[string]expr.Value{ | 590 | for k, v := range map[string]expr.Value{ | ||
| 571 | "github": github, "needs": needs, "strategy": strategy, "matrix": matrix, | 591 | "github": github, "needs": needs, "strategy": strategy, "matrix": matrix, | ||
| 572 | - | "inputs": expr.NewDictionary(), "vars": vars, | 592 | + | "inputs": inputs, "vars": vars, |
| 573 | } { | 593 | } { | ||
| 574 | out[k] = json.RawMessage(compactJSON(expr.ToJSON(v))) | 594 | out[k] = json.RawMessage(compactJSON(expr.ToJSON(v))) | ||
| 575 | } | 595 | } | ||
A internal/usecase/ci_reusable.go +1298 -0
| @@ -0,0 +1,1298 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "encoding/hex" | |
| 9 | + | "encoding/json" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "io" | |
| 13 | + | "slices" | |
| 14 | + | "strconv" | |
| 15 | + | "strings" | |
| 16 | + | ||
| 17 | + | "go.yaml.in/yaml/v3" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite-actions/expr" | |
| 20 | + | "bnema.dev/ermite-actions/template" | |
| 21 | + | "bnema.dev/ermite-actions/workflow" | |
| 22 | + | "bnema.dev/ermite/internal/domain" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // Reusable workflows (CI-3, D7). ermite owns the job graph, so a job with | |
| 26 | + | // "uses:" is expanded at planning, not by the runner: | |
| 27 | + | // | |
| 28 | + | // - the job becomes a call record (one per matrix leg) that never runs; it ends | |
| 29 | + | // when the jobs of its scope have, with their aggregate conclusion, and its | |
| 30 | + | // outputs are the callee's "on.workflow_call.outputs"; | |
| 31 | + | // - each job of the called workflow becomes a record of that scope, keyed | |
| 32 | + | // "<caller key>/<callee id>", named "<caller name> / <callee name>", planned | |
| 33 | + | // when the callee's own "needs" have ended (the caller's "needs" ended | |
| 34 | + | // before the call was expanded, so they hold for every callee root); | |
| 35 | + | // - the called file is kept in the call record, so every job of the call runs | |
| 36 | + | // from the file of the commit that was resolved, and the runner is given that | |
| 37 | + | // file and the callee job id. | |
| 38 | + | // | |
| 39 | + | // Texts are the ones of actions/runner (ReusableWorkflowsLoader.cs, | |
| 40 | + | // WorkflowTemplateConverter.cs, PermissionsHelper.cs) and languageservices | |
| 41 | + | // (validate.ts) where they exist. | |
| 42 | + | ||
| 43 | + | // maxReusableWorkflowDepth is the nesting limit of reusable workflows | |
| 44 | + | // (languageservices workflow-parser/src/model/convert.ts:49, 4; the runner's | |
| 45 | + | // check is ReusableWorkflowsLoader.LoadRecursive). | |
| 46 | + | const maxReusableWorkflowDepth = 4 | |
| 47 | + | ||
| 48 | + | // maxJobIndex is the highest job position a check id can hold (checkIndexBits). | |
| 49 | + | const maxJobIndex = 1<<checkIndexBits - 1 | |
| 50 | + | ||
| 51 | + | // callRef is a parsed "uses:" of a job. | |
| 52 | + | type callRef struct { | |
| 53 | + | local bool | |
| 54 | + | owner, repo string | |
| 55 | + | path string // .github/workflows/x.yml | |
| 56 | + | ref string | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // parseCallRef reads the "uses:" of a job calling a workflow. The texts are | |
| 60 | + | // languageservices' (validate.ts validateWorkflowUsesFormat). | |
| 61 | + | func parseCallRef(uses string) (callRef, string) { | |
| 62 | + | bad := func(reason string) (callRef, string) { | |
| 63 | + | return callRef{}, "Invalid workflow reference '" + uses + "': " + reason | |
| 64 | + | } | |
| 65 | + | if strings.HasPrefix(uses, "./.github/workflows/") { | |
| 66 | + | switch { | |
| 67 | + | case strings.Contains(uses, "@"): | |
| 68 | + | return bad("cannot specify version when calling local workflows") | |
| 69 | + | case !strings.HasSuffix(uses, ".yml") && !strings.HasSuffix(uses, ".yaml"): | |
| 70 | + | return bad("workflow file should have either a '.yml' or '.yaml' file extension") | |
| 71 | + | case len(strings.Split(uses, "/")) != 4: | |
| 72 | + | return bad("workflows must be defined at the top level of the .github/workflows/ directory") | |
| 73 | + | case strings.HasSuffix(uses, "/.yml") || strings.HasSuffix(uses, "/.yaml"): | |
| 74 | + | return bad("invalid workflow file name") | |
| 75 | + | } | |
| 76 | + | return callRef{local: true, path: strings.TrimPrefix(uses, "./")}, "" | |
| 77 | + | } | |
| 78 | + | if strings.HasPrefix(uses, "./") { | |
| 79 | + | return bad("local workflow references must be rooted in '.github/workflows'") | |
| 80 | + | } | |
| 81 | + | parts := strings.Split(uses, "@") | |
| 82 | + | switch { | |
| 83 | + | case len(parts) == 1 || (len(parts) == 2 && parts[1] == ""): | |
| 84 | + | return bad("no version specified") | |
| 85 | + | case len(parts) > 2: | |
| 86 | + | return bad("too many '@' in workflow reference") | |
| 87 | + | } | |
| 88 | + | i := strings.Index(parts[0], ".github/workflows/") | |
| 89 | + | if i < 0 { | |
| 90 | + | return bad("references to workflows must be rooted in '.github/workflows'") | |
| 91 | + | } | |
| 92 | + | nwo := strings.FieldsFunc(parts[0][:i], func(r rune) bool { return r == '/' }) | |
| 93 | + | if len(nwo) != 2 { | |
| 94 | + | return bad("references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows") | |
| 95 | + | } | |
| 96 | + | if !validActionName(nwo[0]) || !validActionName(nwo[1]) { | |
| 97 | + | return bad("owner name must be a valid repository owner name") | |
| 98 | + | } | |
| 99 | + | path := parts[0][i:] | |
| 100 | + | for _, s := range strings.Split(path, "/") { | |
| 101 | + | if s == "" || s == "." || s == ".." { | |
| 102 | + | return bad("references to workflows must be rooted in '.github/workflows'") | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | return callRef{owner: nwo[0], repo: nwo[1], path: path, ref: parts[1]}, "" | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // callFailure is a call that cannot be expanded; text is the job's error. | |
| 109 | + | type callFailure struct{ text string } | |
| 110 | + | ||
| 111 | + | func (e callFailure) Error() string { return e.text } | |
| 112 | + | ||
| 113 | + | func isCallFailure(err error) (string, bool) { | |
| 114 | + | var cf callFailure | |
| 115 | + | if errors.As(err, &cf) { | |
| 116 | + | return cf.text, true | |
| 117 | + | } | |
| 118 | + | return "", false | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // calledFile is a workflow file read for a call. | |
| 122 | + | type calledFile struct { | |
| 123 | + | repository, path, ref, sha string | |
| 124 | + | yaml []byte | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // loadCallee finds the file a "uses:" names. home is the call whose file holds | |
| 128 | + | // the "uses:" (nil for the run's own workflow): a local reference is read in | |
| 129 | + | // the repository and at the commit of that file. | |
| 130 | + | func (c *CI) loadCallee(ctx context.Context, rs *runState, home *domain.CICall, rec domain.CIJob, ref callRef) (calledFile, error) { | |
| 131 | + | var f calledFile | |
| 132 | + | if ref.local { | |
| 133 | + | f.path = ref.path | |
| 134 | + | if home == nil { | |
| 135 | + | f.repository, f.sha = rs.run.Repo.String(), rs.run.Sha | |
| 136 | + | } else { | |
| 137 | + | f.repository, f.sha = home.Repository, home.Sha | |
| 138 | + | } | |
| 139 | + | return f, c.readCallee(ctx, &f) | |
| 140 | + | } | |
| 141 | + | f.path, f.ref = ref.path, ref.ref | |
| 142 | + | ar := actionRef{uses: ref.owner + "/" + ref.repo + "@" + ref.ref, owner: ref.owner, repo: ref.repo, ref: ref.ref} | |
| 143 | + | by := c.onBehalfOf(*rs.src) | |
| 144 | + | // One resolution per reference and attempt: what this planning resolved | |
| 145 | + | // already (not stored yet), else what the attempt stored, else a fresh one. | |
| 146 | + | var res domain.CIActionResolution | |
| 147 | + | found := false | |
| 148 | + | for _, r := range rs.resolutions { | |
| 149 | + | if r.RunID == rec.RunID && r.RunAttempt == rec.RunAttempt && r.Key == ar.key() { | |
| 150 | + | res, found = r, true | |
| 151 | + | break | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | if !found { | |
| 155 | + | stored, err := c.cfg.Store.GetActionResolution(ctx, rec.RunID, rec.RunAttempt, ar.key()) | |
| 156 | + | switch { | |
| 157 | + | case err == nil: | |
| 158 | + | res = stored | |
| 159 | + | case errors.Is(err, domain.ErrNotFound): | |
| 160 | + | var msg string | |
| 161 | + | res, msg, err = c.resolveAction(ctx, rec, ar, newRemoteRefs(c.cfg.Remote, c.remoteSlots), by) | |
| 162 | + | if err != nil { | |
| 163 | + | return f, err | |
| 164 | + | } | |
| 165 | + | if msg != "" { | |
| 166 | + | return f, callFailure{msg} | |
| 167 | + | } | |
| 168 | + | rs.resolutions = append(rs.resolutions, res) | |
| 169 | + | default: | |
| 170 | + | return f, err | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | // The source guard of ResolveActions (answer), whatever the resolution came | |
| 174 | + | // from: a repository of this server that the actor cannot read is never | |
| 175 | + | // served from anywhere else, and a stored ermite resolution of a repository | |
| 176 | + | // that is gone has nothing to serve. | |
| 177 | + | repo, onErmite, err := c.ermiteRepoOf(ctx, ar) | |
| 178 | + | if err != nil { | |
| 179 | + | return f, err | |
| 180 | + | } | |
| 181 | + | if onErmite { | |
| 182 | + | if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil { | |
| 183 | + | return f, err | |
| 184 | + | } else if !ok { | |
| 185 | + | return f, callFailure{errUnresolvedRepo(ar)} | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | f.sha = res.ResolvedSHA | |
| 189 | + | switch res.Source { | |
| 190 | + | case domain.CIActionErmite: | |
| 191 | + | if !onErmite { | |
| 192 | + | return f, callFailure{errUnresolvedRepo(ar)} | |
| 193 | + | } | |
| 194 | + | f.repository = repo.Name.String() | |
| 195 | + | default: | |
| 196 | + | f.repository = ref.owner + "/" + ref.repo | |
| 197 | + | } | |
| 198 | + | return f, c.readCallee(ctx, &f) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // readCallee fills f.yaml from the repository of the server or github.com. | |
| 202 | + | func (c *CI) readCallee(ctx context.Context, f *calledFile) error { | |
| 203 | + | notFound := callFailure{"Unable to find reusable workflow '" + f.path + "' in '" + f.repository + "' at '" + f.sha + "'"} | |
| 204 | + | if owner, repo, ok := strings.Cut(f.repository, "/"); ok { | |
| 205 | + | if c.cfg.Workflows == nil { | |
| 206 | + | return fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable) | |
| 207 | + | } | |
| 208 | + | data, err := c.cfg.Workflows.FetchWorkflow(ctx, owner, repo, f.sha, f.path, maxCIWorkflowBytes) | |
| 209 | + | switch { | |
| 210 | + | case errors.Is(err, domain.ErrNotFound): | |
| 211 | + | return notFound | |
| 212 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 213 | + | return callFailure{f.path + ": the workflow file is larger than 1 MiB"} | |
| 214 | + | case err != nil: | |
| 215 | + | return err | |
| 216 | + | } | |
| 217 | + | f.yaml = data | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | name, err := domain.ParseRepoName(f.repository) | |
| 221 | + | if err != nil { | |
| 222 | + | return notFound | |
| 223 | + | } | |
| 224 | + | id, err := domain.ParseObjectID(f.sha) | |
| 225 | + | if err != nil { | |
| 226 | + | return notFound | |
| 227 | + | } | |
| 228 | + | tp, err := domain.ParseTreePath(f.path) | |
| 229 | + | if err != nil { | |
| 230 | + | return notFound | |
| 231 | + | } | |
| 232 | + | _, rc, err := c.cfg.Reader.OpenBlob(ctx, name, domain.RevisionOf(id), tp) | |
| 233 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 234 | + | return notFound | |
| 235 | + | } | |
| 236 | + | if err != nil { | |
| 237 | + | return err | |
| 238 | + | } | |
| 239 | + | defer func() { _ = rc.Close() }() | |
| 240 | + | data, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1)) | |
| 241 | + | if err != nil { | |
| 242 | + | return err | |
| 243 | + | } | |
| 244 | + | if len(data) > maxCIWorkflowBytes { | |
| 245 | + | return callFailure{f.path + ": the workflow file is larger than 1 MiB"} | |
| 246 | + | } | |
| 247 | + | f.yaml = data | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // callerPrefix is the text GitHub puts before an error of the called workflow | |
| 252 | + | // (TemplateStrings: CalledWorkflowNotValidWithErrors, LineColumn). | |
| 253 | + | func callerPrefix(wj *workflow.Job) string { | |
| 254 | + | file, pos := "", template.Pos{} | |
| 255 | + | if wj.Uses != nil { | |
| 256 | + | file, pos = wj.Uses.File(), wj.Uses.Pos() | |
| 257 | + | } | |
| 258 | + | return fmt.Sprintf("In %s (Line: %d, Col: %d): Error from called workflow", file, pos.Line, pos.Col) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // calleeSpec is what ermite reads of "on.workflow_call" that the workflow model | |
| 262 | + | // does not keep: the outputs and the secrets (ermite-actions parses the inputs). | |
| 263 | + | type calleeSpec struct { | |
| 264 | + | outputs map[string]string | |
| 265 | + | secrets map[string]bool // name (upper case) to required | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // readCalleeSpec reads on.workflow_call.outputs and .secrets from the YAML. | |
| 269 | + | func readCalleeSpec(src []byte) calleeSpec { | |
| 270 | + | spec := calleeSpec{outputs: map[string]string{}, secrets: map[string]bool{}} | |
| 271 | + | var doc yaml.Node | |
| 272 | + | if yaml.Unmarshal(src, &doc) != nil || len(doc.Content) == 0 { | |
| 273 | + | return spec | |
| 274 | + | } | |
| 275 | + | root := derefYAML(doc.Content[0]) | |
| 276 | + | for i := 0; root.Kind == yaml.MappingNode && i+1 < len(root.Content); i += 2 { | |
| 277 | + | if root.Content[i].Value != "on" { | |
| 278 | + | continue | |
| 279 | + | } | |
| 280 | + | on := derefYAML(root.Content[i+1]) | |
| 281 | + | for j := 0; on.Kind == yaml.MappingNode && j+1 < len(on.Content); j += 2 { | |
| 282 | + | if on.Content[j].Value != "workflow_call" { | |
| 283 | + | continue | |
| 284 | + | } | |
| 285 | + | wc := derefYAML(on.Content[j+1]) | |
| 286 | + | for k := 0; wc.Kind == yaml.MappingNode && k+1 < len(wc.Content); k += 2 { | |
| 287 | + | body := derefYAML(wc.Content[k+1]) | |
| 288 | + | for m := 0; body.Kind == yaml.MappingNode && m+1 < len(body.Content); m += 2 { | |
| 289 | + | name, def := body.Content[m].Value, derefYAML(body.Content[m+1]) | |
| 290 | + | switch wc.Content[k].Value { | |
| 291 | + | case "outputs": | |
| 292 | + | for q := 0; def.Kind == yaml.MappingNode && q+1 < len(def.Content); q += 2 { | |
| 293 | + | if def.Content[q].Value == "value" { | |
| 294 | + | spec.outputs[name] = derefYAML(def.Content[q+1]).Value | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | case "secrets": | |
| 298 | + | req := false | |
| 299 | + | for q := 0; def.Kind == yaml.MappingNode && q+1 < len(def.Content); q += 2 { | |
| 300 | + | if def.Content[q].Value == "required" { | |
| 301 | + | req = derefYAML(def.Content[q+1]).Value == "true" | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | spec.secrets[strings.ToUpper(name)] = req | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | return spec | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func derefYAML(n *yaml.Node) *yaml.Node { | |
| 314 | + | for range 16 { | |
| 315 | + | if n.Kind != yaml.AliasNode || n.Alias == nil { | |
| 316 | + | return n | |
| 317 | + | } | |
| 318 | + | n = n.Alias | |
| 319 | + | } | |
| 320 | + | return n | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // callInputs checks the "with:" of a call against the inputs of the called | |
| 324 | + | // workflow and returns the inputs context: the value given, else the default, | |
| 325 | + | // else the zero of the type. Texts: WorkflowTemplateConverter.cs | |
| 326 | + | // (ConvertToWorkflowJobInputs, ValidateUndefinedParameters). | |
| 327 | + | func callInputs(ctx context.Context, defs []workflow.Input, given *expr.Dictionary, defEnv template.Env) (*expr.Dictionary, error) { | |
| 328 | + | out := expr.NewDictionary() | |
| 329 | + | byName := map[string]bool{} | |
| 330 | + | for _, in := range defs { | |
| 331 | + | byName[strings.ToLower(in.Name)] = true | |
| 332 | + | var v expr.Value | |
| 333 | + | have := false | |
| 334 | + | if given != nil { | |
| 335 | + | for _, p := range given.Pairs() { | |
| 336 | + | if strings.EqualFold(p.Key, in.Name) { | |
| 337 | + | cv, err := coerceInput(in, p.Value) | |
| 338 | + | if err != nil { | |
| 339 | + | return nil, err | |
| 340 | + | } | |
| 341 | + | v, have = cv, true | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | if !have && in.HasDefault { | |
| 346 | + | dv, err := evalInterpolated(ctx, in.Default, defEnv) | |
| 347 | + | if err != nil { | |
| 348 | + | return nil, err | |
| 349 | + | } | |
| 350 | + | cv, err := coerceInput(in, dv) | |
| 351 | + | if err != nil { | |
| 352 | + | return nil, err | |
| 353 | + | } | |
| 354 | + | v, have = cv, true | |
| 355 | + | } | |
| 356 | + | if !have && in.Required { | |
| 357 | + | return nil, callFailure{"Input " + in.Name + " is required, but not provided while calling."} | |
| 358 | + | } | |
| 359 | + | if !have { | |
| 360 | + | switch in.Type { | |
| 361 | + | case "boolean": | |
| 362 | + | v = expr.Boolean(false) | |
| 363 | + | case "number": | |
| 364 | + | v = expr.Number(0) | |
| 365 | + | default: | |
| 366 | + | v = expr.String("") | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | out.Add(in.Name, v) | |
| 370 | + | } | |
| 371 | + | if given != nil { | |
| 372 | + | for _, p := range given.Pairs() { | |
| 373 | + | if !byName[strings.ToLower(p.Key)] { | |
| 374 | + | return nil, callFailure{"Invalid input, " + p.Key + " is not defined in the referenced workflow."} | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | return out, nil | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // coerceInput converts a value to the type of an input, as the template engine | |
| 382 | + | // does for a boolean, number or string (GetDefaultValueByType, | |
| 383 | + | // ConvertToInputValueDefinedType): a mismatch is "Unexpected value". | |
| 384 | + | func coerceInput(in workflow.Input, v expr.Value) (expr.Value, error) { | |
| 385 | + | unexpected := func() (expr.Value, error) { | |
| 386 | + | return nil, callFailure{"Unexpected value '" + expr.CoerceString(v) + "' for input '" + in.Name + "' of type '" + in.Type + "'"} | |
| 387 | + | } | |
| 388 | + | switch in.Type { | |
| 389 | + | case "boolean": | |
| 390 | + | switch x := v.(type) { | |
| 391 | + | case expr.Boolean: | |
| 392 | + | return x, nil | |
| 393 | + | case expr.String: | |
| 394 | + | switch strings.ToLower(string(x)) { | |
| 395 | + | case "true": | |
| 396 | + | return expr.Boolean(true), nil | |
| 397 | + | case "false": | |
| 398 | + | return expr.Boolean(false), nil | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | return unexpected() | |
| 402 | + | case "number": | |
| 403 | + | switch x := v.(type) { | |
| 404 | + | case expr.Number: | |
| 405 | + | return x, nil | |
| 406 | + | case expr.String: | |
| 407 | + | if f, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64); err == nil { | |
| 408 | + | return expr.Number(f), nil | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | return unexpected() | |
| 412 | + | } | |
| 413 | + | switch v.(type) { | |
| 414 | + | case expr.String, expr.Number, expr.Boolean: | |
| 415 | + | if s, ok := v.(expr.String); ok { | |
| 416 | + | return s, nil | |
| 417 | + | } | |
| 418 | + | return expr.String(expr.CoerceString(v)), nil | |
| 419 | + | } | |
| 420 | + | return unexpected() | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | // evalInterpolated evaluates a string with ${{ }} expressions (a default of an | |
| 424 | + | // input, the value of an output). A string that is one expression keeps the | |
| 425 | + | // type of its value. | |
| 426 | + | func evalInterpolated(ctx context.Context, s string, env template.Env) (expr.Value, error) { | |
| 427 | + | if !strings.Contains(s, "${{") { | |
| 428 | + | return expr.String(s), nil | |
| 429 | + | } | |
| 430 | + | ctxNames := make([]string, 0, len(env.Contexts)) | |
| 431 | + | for k := range env.Contexts { | |
| 432 | + | ctxNames = append(ctxNames, k) | |
| 433 | + | } | |
| 434 | + | eval := func(src string) (expr.Value, error) { | |
| 435 | + | node, err := expr.Parse(src, ctxNames, exprFuncs) | |
| 436 | + | if err != nil { | |
| 437 | + | return nil, err | |
| 438 | + | } | |
| 439 | + | return expr.Evaluate(ctx, node, expr.EvalContext{Contexts: env.Contexts}) | |
| 440 | + | } | |
| 441 | + | exprs := expressionsIn(s) | |
| 442 | + | if t := strings.TrimSpace(s); len(exprs) == 1 && strings.HasPrefix(t, "${{") && strings.HasSuffix(t, "}}") && strings.Count(t, "${{") == 1 { | |
| 443 | + | return eval(exprs[0]) | |
| 444 | + | } | |
| 445 | + | var b strings.Builder | |
| 446 | + | rest := s | |
| 447 | + | for _, src := range exprs { | |
| 448 | + | i := strings.Index(rest, "${{") | |
| 449 | + | b.WriteString(rest[:i]) | |
| 450 | + | v, err := eval(src) | |
| 451 | + | if err != nil { | |
| 452 | + | return nil, err | |
| 453 | + | } | |
| 454 | + | b.WriteString(expr.CoerceString(v)) | |
| 455 | + | rest = rest[i+3+len(src)+2:] | |
| 456 | + | } | |
| 457 | + | b.WriteString(rest) | |
| 458 | + | return expr.String(b.String()), nil | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | // calleeOf parses the workflow a call record kept. | |
| 462 | + | func (rs *runState) calleeOf(call *domain.CIJob) (*workflow.Workflow, error) { | |
| 463 | + | if call == nil || call.Call == nil { | |
| 464 | + | return nil, fmt.Errorf("the call of this job is gone") | |
| 465 | + | } | |
| 466 | + | return rs.c.flows.parseCalled(call.Call.Path, call.Call.YAML) | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // scopeOf finds the call record of a scoped job, nil for a job of the run's own | |
| 470 | + | // workflow. | |
| 471 | + | func (rs *runState) scopeOf(j domain.CIJob) *domain.CIJob { | |
| 472 | + | if j.Scope == "" { | |
| 473 | + | return nil | |
| 474 | + | } | |
| 475 | + | return rs.find(j.Scope) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // inputsOf is the "inputs" context of a job: its call's inputs, empty at the | |
| 479 | + | // top. | |
| 480 | + | func (rs *runState) inputsOf(j domain.CIJob) *expr.Dictionary { | |
| 481 | + | if sc := rs.scopeOf(j); sc != nil && sc.Call != nil { | |
| 482 | + | if v, err := expr.FromJSON([]byte(sc.Call.InputsJSON)); err == nil { | |
| 483 | + | if d, ok := v.(*expr.Dictionary); ok { | |
| 484 | + | return d | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | return expr.NewDictionary() | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // instancesIn returns the indexes of the records of job id in a scope. | |
| 492 | + | func (rs *runState) instancesIn(scope, id string) []int { | |
| 493 | + | var out []int | |
| 494 | + | for i, j := range rs.jobs { | |
| 495 | + | if j.Scope == scope && ((scope == "" && j.Key == id) || (scope != "" && j.CallID == id)) { | |
| 496 | + | out = append(out, i) | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | return out | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | // rootKey is the key of the job of the run's own workflow a record descends | |
| 503 | + | // from. | |
| 504 | + | func rootKey(key string) string { | |
| 505 | + | root, _, _ := strings.Cut(key, "/") | |
| 506 | + | return root | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | // nextIndex is a position for a job added to the run. | |
| 510 | + | func (rs *runState) nextIndex() int { | |
| 511 | + | n := 0 | |
| 512 | + | for _, j := range rs.jobs { | |
| 513 | + | n = max(n, j.Index+1) | |
| 514 | + | } | |
| 515 | + | return n | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // planCall expands a job that calls a reusable workflow. rec (at idx) becomes | |
| 519 | + | // the first call record, one per matrix leg; the jobs of the called workflow are | |
| 520 | + | // added as waiting records of the scope of each leg. | |
| 521 | + | func (rs *runState) planCall(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, idx int, env template.Env) error { | |
| 522 | + | rec := rs.jobs[idx] | |
| 523 | + | parent := rs.scopeOf(rec) | |
| 524 | + | var home *domain.CICall | |
| 525 | + | depth := 1 | |
| 526 | + | if parent != nil { | |
| 527 | + | home, depth = parent.Call, parent.Call.Depth+1 | |
| 528 | + | } | |
| 529 | + | if depth > maxReusableWorkflowDepth { | |
| 530 | + | return callFailure{fmt.Sprintf("Nested reusable workflow depth exceeded %d.", maxReusableWorkflowDepth)} | |
| 531 | + | } | |
| 532 | + | ref, text := parseCallRef(wj.Uses.Value) | |
| 533 | + | if text != "" { | |
| 534 | + | return callFailure{text} | |
| 535 | + | } | |
| 536 | + | file, err := rs.c.loadCallee(ctx, rs, home, rec, ref) | |
| 537 | + | if err != nil { | |
| 538 | + | return err | |
| 539 | + | } | |
| 540 | + | prefix := callerPrefix(wj) | |
| 541 | + | cw, errs := workflow.Parse(file.path, file.yaml) | |
| 542 | + | if len(errs) > 0 { | |
| 543 | + | return callFailure{prefix + " " + errs[0].Error()} | |
| 544 | + | } | |
| 545 | + | wc := cw.On.Event("workflow_call") | |
| 546 | + | if wc == nil { | |
| 547 | + | return callFailure{prefix + " workflow_call key is not defined in the referenced workflow."} | |
| 548 | + | } | |
| 549 | + | spec := readCalleeSpec(file.yaml) | |
| 550 | + | ||
| 551 | + | eff, err := rs.callPermissions(wf, wj, rec, parent) | |
| 552 | + | if err != nil { | |
| 553 | + | return err | |
| 554 | + | } | |
| 555 | + | plan, err := wj.EvaluateStrategy(ctx, env) | |
| 556 | + | if err != nil { | |
| 557 | + | return err | |
| 558 | + | } | |
| 559 | + | vars, _ := env.Contexts["vars"].(*expr.Dictionary) | |
| 560 | + | if vars == nil { | |
| 561 | + | vars = expr.NewDictionary() | |
| 562 | + | } | |
| 563 | + | gh := env.Contexts["github"] | |
| 564 | + | ||
| 565 | + | // The records of the run are bounded before anything changes: the calls, and | |
| 566 | + | // the jobs of the called workflow for each (a job that is a matrix adds its | |
| 567 | + | // legs when it is planned, checked there). | |
| 568 | + | if err := rs.checkJobBudget(len(plan.Combos) - 1 + len(plan.Combos)*len(cw.Jobs)); err != nil { | |
| 569 | + | return err | |
| 570 | + | } | |
| 571 | + | legs := make([]domain.CIJob, 0, len(plan.Combos)) | |
| 572 | + | for i := range plan.Combos { | |
| 573 | + | menv, err := plan.MatrixEnv(env, i) | |
| 574 | + | if err != nil { | |
| 575 | + | return err | |
| 576 | + | } | |
| 577 | + | var given *expr.Dictionary | |
| 578 | + | if wj.With != nil { | |
| 579 | + | v, err := template.Evaluate(ctx, wj.With, menv) | |
| 580 | + | if err != nil { | |
| 581 | + | return err | |
| 582 | + | } | |
| 583 | + | given, _ = v.(*expr.Dictionary) | |
| 584 | + | } | |
| 585 | + | inEnv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars}} | |
| 586 | + | inputs, err := callInputs(ctx, wc.Inputs, given, inEnv) | |
| 587 | + | if err != nil { | |
| 588 | + | if text, ok := isCallFailure(err); ok { | |
| 589 | + | return callFailure{prefix + " " + text} | |
| 590 | + | } | |
| 591 | + | return err | |
| 592 | + | } | |
| 593 | + | // The group of the calling job (job-level, evaluated like the group of | |
| 594 | + | // any job) and the workflow-level group of the called workflow | |
| 595 | + | // (evaluated with the caller's github context and the call's inputs). | |
| 596 | + | // The call record is the entry of both. | |
| 597 | + | var jobConc, wfConc domain.CIConcurrency | |
| 598 | + | if wj.Concurrency != nil { | |
| 599 | + | if jobConc, err = evalConcurrency(ctx, wj.Concurrency, menv); err != nil { | |
| 600 | + | return err | |
| 601 | + | } | |
| 602 | + | jobConc.Evaluated = true | |
| 603 | + | } | |
| 604 | + | if cw.Concurrency != nil { | |
| 605 | + | wenv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": inputs, "vars": vars}} | |
| 606 | + | if wfConc, err = evalConcurrency(ctx, cw.Concurrency, wenv); err != nil { | |
| 607 | + | return callFailure{prefix + " " + err.Error()} | |
| 608 | + | } | |
| 609 | + | wfConc.Evaluated = true | |
| 610 | + | } | |
| 611 | + | call := domain.CICall{ | |
| 612 | + | Uses: wj.Uses.Value, Path: file.path, Repository: file.repository, Ref: file.ref, Sha: file.sha, YAML: string(file.yaml), | |
| 613 | + | InputsJSON: compactJSON(expr.ToJSON(inputs)), Depth: depth, Expanded: true, Concurrency: wfConc, | |
| 614 | + | } | |
| 615 | + | if err := rs.callSecrets(wj, menv, spec, &call); err != nil { | |
| 616 | + | if text, ok := isCallFailure(err); ok { | |
| 617 | + | return callFailure{prefix + " " + text} | |
| 618 | + | } | |
| 619 | + | return err | |
| 620 | + | } | |
| 621 | + | name, err := wj.DisplayName(ctx, menv, plan, i) | |
| 622 | + | if err != nil { | |
| 623 | + | return err | |
| 624 | + | } | |
| 625 | + | leg := rec | |
| 626 | + | leg.Combo = i | |
| 627 | + | leg.ID = domain.NewJobID(rs.run.ID, rec.Index, i) | |
| 628 | + | if i > 0 { | |
| 629 | + | leg.Version = 0 | |
| 630 | + | } | |
| 631 | + | leg.Name = strings.TrimSpace(name) | |
| 632 | + | if parent != nil { | |
| 633 | + | leg.Name = parent.Name + " / " + leg.Name // "<caller> / <nested caller>" | |
| 634 | + | } | |
| 635 | + | leg.Call, leg.Planned, leg.Phase = &call, true, domain.CIPhaseWaiting | |
| 636 | + | leg.Permissions = eff | |
| 637 | + | leg.Concurrency = jobConc | |
| 638 | + | leg.MatrixJSON = compactJSON(expr.ToJSON(plan.Combos[i].Values)) | |
| 639 | + | leg.ContextsJSON, leg.ComboTotal = "{}", len(plan.Combos) | |
| 640 | + | leg.FailFast, leg.MaxParallel = plan.FailFast, plan.MaxParallel | |
| 641 | + | leg.Labels, leg.TimeoutMinutes = nil, 0 | |
| 642 | + | legs = append(legs, leg) | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | // The new positions follow the highest one in use. | |
| 646 | + | next := rs.nextIndex() | |
| 647 | + | if next+len(legs)*len(cw.Jobs) > maxJobIndex+1 { | |
| 648 | + | return callFailure{"The workflow has more jobs than the server tracks (" + strconv.Itoa(maxJobIndex+1) + ")."} | |
| 649 | + | } | |
| 650 | + | rs.jobs[idx] = legs[0] | |
| 651 | + | rs.touch(&rs.jobs[idx]) | |
| 652 | + | for _, l := range legs[1:] { | |
| 653 | + | rs.jobs = append(rs.jobs, l) | |
| 654 | + | rs.dirty[l.ID] = true | |
| 655 | + | } | |
| 656 | + | next = rs.nextIndex() | |
| 657 | + | now := rs.c.now() | |
| 658 | + | for _, l := range legs { | |
| 659 | + | for _, cj := range cw.Jobs { | |
| 660 | + | needs := make([]string, len(cj.Needs)) | |
| 661 | + | for i, n := range cj.Needs { | |
| 662 | + | needs[i] = l.Key + "/" + n | |
| 663 | + | } | |
| 664 | + | id := domain.NewJobID(rs.run.ID, next, 0) | |
| 665 | + | rs.jobs = append(rs.jobs, domain.CIJob{ | |
| 666 | + | ID: id, RunID: rs.run.ID, Attempt: rs.attempt, RunAttempt: rs.attempt, Repo: rs.run.Repo, Key: l.Key + "/" + cj.ID, | |
| 667 | + | Name: cj.ID, Phase: domain.CIPhaseWaiting, Needs: needs, Index: next, CreatedAt: now, Scope: l.ID, CallID: cj.ID, | |
| 668 | + | }) | |
| 669 | + | rs.dirty[id] = true | |
| 670 | + | next++ | |
| 671 | + | } | |
| 672 | + | } | |
| 673 | + | rs.sortJobs() | |
| 674 | + | return nil | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | // callPermissions is the permissions a call grants to the jobs of its scope: the | |
| 678 | + | // "permissions:" of the calling job, else of its workflow, else those the | |
| 679 | + | // containing call allows. A reusable workflow can only keep or lower them: a | |
| 680 | + | // request above the allowed is the error of PermissionsHelper.cs. | |
| 681 | + | func (rs *runState) callPermissions(wf *workflow.Workflow, wj *workflow.Job, rec domain.CIJob, parent *domain.CIJob) (map[string]string, error) { | |
| 682 | + | got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |
| 683 | + | if parent == nil { | |
| 684 | + | return got, nil | |
| 685 | + | } | |
| 686 | + | allowed := domain.TokenPermissions(parent.Permissions, true) | |
| 687 | + | if got == nil { | |
| 688 | + | return allowed, nil | |
| 689 | + | } | |
| 690 | + | if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 { | |
| 691 | + | return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. The nested job '%s' is requesting '%s', but is only allowed '%s'.", | |
| 692 | + | parent.Call.Uses, wj.ID, strings.Join(asked, ", "), strings.Join(ok, ", "))} | |
| 693 | + | } | |
| 694 | + | return got, nil | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | // scopedPermissions are the permissions of a job of a called workflow: its own | |
| 698 | + | // "permissions:", else its workflow's, else what the call allows; asking for | |
| 699 | + | // more than the call allows is an error. | |
| 700 | + | func (rs *runState) scopedPermissions(wf *workflow.Workflow, wj *workflow.Job, call *domain.CIJob) (map[string]string, error) { | |
| 701 | + | allowed := domain.TokenPermissions(call.Permissions, true) | |
| 702 | + | got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |
| 703 | + | if got == nil { | |
| 704 | + | return call.Permissions, nil | |
| 705 | + | } | |
| 706 | + | if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 { | |
| 707 | + | what := "The nested job '" + wj.ID + "'" | |
| 708 | + | if wj.Permissions == nil { | |
| 709 | + | what = "The workflow" | |
| 710 | + | } | |
| 711 | + | return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. %s is requesting '%s', but is only allowed '%s'.", | |
| 712 | + | call.Call.Uses, what, strings.Join(asked, ", "), strings.Join(ok, ", "))} | |
| 713 | + | } | |
| 714 | + | return got, nil | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | // callSecrets reads the "secrets:" of a call: "inherit", or a mapping. Planning | |
| 718 | + | // validates names only (the expressions were checked against the contexts the | |
| 719 | + | // schema allows when the file was parsed) and keeps no expression and no value: | |
| 720 | + | // the mapping is read from the pinned workflow file, as a template token, when | |
| 721 | + | // a job is assigned (callMapping, mapSecrets), and evaluated once there. Only | |
| 722 | + | // the plain contexts the expressions read (needs, matrix, strategy, inputs, | |
| 723 | + | // vars: none is secret) are kept, in call.SecretCtx, as they are now. | |
| 724 | + | func (rs *runState) callSecrets(wj *workflow.Job, env template.Env, spec calleeSpec, call *domain.CICall) error { | |
| 725 | + | if wj.InheritSecrets { | |
| 726 | + | call.Inherit = true | |
| 727 | + | return nil | |
| 728 | + | } | |
| 729 | + | if wj.Secrets == nil { | |
| 730 | + | return rs.checkRequiredSecrets(spec, map[string]bool{}) | |
| 731 | + | } | |
| 732 | + | call.Secrets = map[string]string{} | |
| 733 | + | given := map[string]bool{} | |
| 734 | + | for _, p := range wj.Secrets.Pairs { | |
| 735 | + | name := strings.ToUpper(p.Key.String()) | |
| 736 | + | if _, ok := spec.secrets[name]; !ok { | |
| 737 | + | return callFailure{"Invalid secret, " + p.Key.String() + " is not defined in the referenced workflow."} | |
| 738 | + | } | |
| 739 | + | given[name] = true | |
| 740 | + | call.Secrets[name] = "" // the name only | |
| 741 | + | } | |
| 742 | + | plain := map[string]json.RawMessage{} | |
| 743 | + | for _, k := range []string{"needs", "matrix", "strategy", "inputs", "vars"} { | |
| 744 | + | if v, ok := env.Contexts[k]; ok && v != nil { | |
| 745 | + | plain[k] = json.RawMessage(compactJSON(expr.ToJSON(v))) | |
| 746 | + | } | |
| 747 | + | } | |
| 748 | + | b, err := json.Marshal(plain) | |
| 749 | + | if err != nil { | |
| 750 | + | return err | |
| 751 | + | } | |
| 752 | + | call.SecretCtx = string(b) | |
| 753 | + | return rs.checkRequiredSecrets(spec, given) | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | // callMapping returns the "secrets:" mapping of a call as the template tokens of | |
| 757 | + | // the workflow file that holds the calling job: the run's own workflow, or the | |
| 758 | + | // called workflow of the call that contains it. The tokens come from the file | |
| 759 | + | // itself and are never rebuilt from text; names are upper-cased. | |
| 760 | + | func (rs *runState) callMapping(cr *domain.CIJob) (map[string]template.Token, error) { | |
| 761 | + | path, yamlText, id := rs.run.WorkflowPath, rs.src.WorkflowYAML, cr.Key | |
| 762 | + | if p := rs.scopeOf(*cr); p != nil && p.Call != nil { | |
| 763 | + | path, yamlText, id = p.Call.Path, p.Call.YAML, cr.CallID | |
| 764 | + | } | |
| 765 | + | wf, err := rs.c.flows.parseCalled(path, yamlText) | |
| 766 | + | if err != nil { | |
| 767 | + | return nil, err | |
| 768 | + | } | |
| 769 | + | wj := wf.Job(id) | |
| 770 | + | if wj == nil { | |
| 771 | + | return nil, fmt.Errorf("the calling job is gone") | |
| 772 | + | } | |
| 773 | + | out := map[string]template.Token{} | |
| 774 | + | if wj.Secrets != nil { | |
| 775 | + | for _, p := range wj.Secrets.Pairs { | |
| 776 | + | out[strings.ToUpper(p.Key.String())] = p.Value | |
| 777 | + | } | |
| 778 | + | } | |
| 779 | + | return out, nil | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | // secretsReadBy returns the secrets a template token refers to: the expressions | |
| 783 | + | // of the token are parsed, not the text they produce. | |
| 784 | + | func secretsReadBy(t template.Token) secretUse { | |
| 785 | + | w := &secretWalk{names: map[string]bool{}} | |
| 786 | + | template.Walk(t, func(x template.Token) bool { | |
| 787 | + | if be, ok := x.(*template.BasicExpression); ok { | |
| 788 | + | w.expression(be.Expression) | |
| 789 | + | } | |
| 790 | + | return true | |
| 791 | + | }) | |
| 792 | + | use := secretUse{All: w.all || w.tooDeep} | |
| 793 | + | for n := range w.names { | |
| 794 | + | use.Names = append(use.Names, n) | |
| 795 | + | } | |
| 796 | + | slices.Sort(use.Names) | |
| 797 | + | return use | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | func (rs *runState) checkRequiredSecrets(spec calleeSpec, given map[string]bool) error { | |
| 801 | + | for _, name := range sortedKeys(spec.secrets) { | |
| 802 | + | if spec.secrets[name] && !given[name] { | |
| 803 | + | return callFailure{"Secret " + name + " is required, but not provided while calling."} | |
| 804 | + | } | |
| 805 | + | } | |
| 806 | + | return nil | |
| 807 | + | } | |
| 808 | + | ||
| 809 | + | // chainOf returns the call records a job runs under, innermost first. | |
| 810 | + | func (rs *runState) chainOf(j domain.CIJob) []*domain.CIJob { | |
| 811 | + | var out []*domain.CIJob | |
| 812 | + | for sc := rs.scopeOf(j); sc != nil && len(out) <= maxReusableWorkflowDepth; sc = rs.scopeOf(*sc) { | |
| 813 | + | out = append(out, sc) | |
| 814 | + | } | |
| 815 | + | return out | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | // scopedSecretUse is the secrets of the run's own workflow that a job of a | |
| 819 | + | // called workflow reads, through the "secrets:" of each call that leads to it. | |
| 820 | + | // inner is the use inside the called workflow of the job. | |
| 821 | + | func (rs *runState) scopedSecretUse(j domain.CIJob) (top, inner secretUse) { | |
| 822 | + | chain := rs.chainOf(j) | |
| 823 | + | inner = secretsUsedBy(chain[0].Call.YAML, j.CallID) | |
| 824 | + | cur := inner | |
| 825 | + | for _, c := range chain { | |
| 826 | + | cur = rs.translateUse(cur, c) | |
| 827 | + | } | |
| 828 | + | return cur, inner | |
| 829 | + | } | |
| 830 | + | ||
| 831 | + | // translateUse turns the secrets a called workflow reads into the secrets its | |
| 832 | + | // caller must hold: with "inherit" the same ones, else the secrets that the | |
| 833 | + | // expressions of the call's mapping, for the ones it reads, refer to (all of | |
| 834 | + | // them when an expression is not a plain name). A mapping that cannot be read | |
| 835 | + | // asks for all of them: the safe side for the job, which then gets none it | |
| 836 | + | // cannot evaluate. | |
| 837 | + | func (rs *runState) translateUse(use secretUse, cr *domain.CIJob) secretUse { | |
| 838 | + | if cr.Call.Inherit { | |
| 839 | + | return use | |
| 840 | + | } | |
| 841 | + | mapping, err := rs.callMapping(cr) | |
| 842 | + | if err != nil { | |
| 843 | + | return secretUse{All: true} | |
| 844 | + | } | |
| 845 | + | var out secretUse | |
| 846 | + | add := func(t template.Token) { | |
| 847 | + | u := secretsReadBy(t) | |
| 848 | + | out.All = out.All || u.All | |
| 849 | + | for _, n := range u.Names { | |
| 850 | + | if !slices.Contains(out.Names, n) { | |
| 851 | + | out.Names = append(out.Names, n) | |
| 852 | + | } | |
| 853 | + | } | |
| 854 | + | } | |
| 855 | + | for name, t := range mapping { | |
| 856 | + | if use.All || slices.Contains(use.Names, name) { | |
| 857 | + | add(t) | |
| 858 | + | } | |
| 859 | + | } | |
| 860 | + | slices.Sort(out.Names) | |
| 861 | + | return out | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | // mapSecrets gives a job of a called workflow the secrets it reads, named as the | |
| 865 | + | // called workflow names them. top holds the secrets of the run's own workflow | |
| 866 | + | // that it may use (empty for a run that withholds them: the caller decides who | |
| 867 | + | // gets any). From the outermost call, each mapping value is the template token | |
| 868 | + | // of the workflow file, evaluated once with the secrets of its calling scope and | |
| 869 | + | // the plain contexts the call kept; the result is a final string, never parsed | |
| 870 | + | // again, and is the secrets of the scope below. A secret not passed (not in | |
| 871 | + | // "secrets:" and no "inherit") is not there. A value that does not evaluate is | |
| 872 | + | // empty, and nothing of the error is logged: it may hold the secret. | |
| 873 | + | func (rs *runState) mapSecrets(ctx context.Context, j domain.CIJob, top map[string]string, inner secretUse) map[string]string { | |
| 874 | + | chain := rs.chainOf(j) | |
| 875 | + | vals := top | |
| 876 | + | gh, ghErr := rs.githubValue("", rs.run.Attempt) | |
| 877 | + | for i := len(chain) - 1; i >= 0; i-- { | |
| 878 | + | cr := chain[i] | |
| 879 | + | if cr.Call.Inherit { | |
| 880 | + | continue | |
| 881 | + | } | |
| 882 | + | mapping, merr := rs.callMapping(cr) | |
| 883 | + | sd := expr.NewDictionary() | |
| 884 | + | for _, k := range sortedKeys(vals) { | |
| 885 | + | sd.Add(k, expr.String(vals[k])) | |
| 886 | + | } | |
| 887 | + | env := template.Env{Contexts: map[string]expr.Value{"secrets": sd}} | |
| 888 | + | if ghErr == nil { | |
| 889 | + | env.Contexts["github"] = gh | |
| 890 | + | } | |
| 891 | + | var plain map[string]json.RawMessage | |
| 892 | + | _ = json.Unmarshal([]byte(cr.Call.SecretCtx), &plain) | |
| 893 | + | for k, raw := range plain { | |
| 894 | + | if v, err := expr.FromJSON(raw); err == nil { | |
| 895 | + | env.Contexts[k] = v | |
| 896 | + | } | |
| 897 | + | } | |
| 898 | + | next := map[string]string{} | |
| 899 | + | for name := range cr.Call.Secrets { | |
| 900 | + | t, ok := mapping[name] | |
| 901 | + | if merr != nil || !ok { | |
| 902 | + | next[name] = "" | |
| 903 | + | continue | |
| 904 | + | } | |
| 905 | + | v, err := template.EvaluateString(ctx, t, env) | |
| 906 | + | if err != nil { | |
| 907 | + | // Never the error: a format() error holds its arguments. | |
| 908 | + | ctxLog(ctx).Warn().Str("secret", name).Msg("ci: a secret passed to a called workflow could not evaluate: it is empty") | |
| 909 | + | v = "" | |
| 910 | + | } | |
| 911 | + | next[name] = v | |
| 912 | + | } | |
| 913 | + | vals = next | |
| 914 | + | } | |
| 915 | + | out := map[string]string{} | |
| 916 | + | for n, v := range vals { | |
| 917 | + | if inner.All || slices.Contains(inner.Names, n) { | |
| 918 | + | out[n] = v | |
| 919 | + | } | |
| 920 | + | } | |
| 921 | + | return out | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | // callHeld reports whether a call holds the concurrency groups it asked for: the | |
| 925 | + | // group of the calling job and the workflow-level group of the called workflow. | |
| 926 | + | // The jobs of a call are not planned before. | |
| 927 | + | func callHeld(call *domain.CIJob) bool { | |
| 928 | + | if call.Call == nil || !call.Call.Admitted { | |
| 929 | + | return false | |
| 930 | + | } | |
| 931 | + | if call.Concurrency.Group != "" && !call.Concurrency.FromRun && call.Concurrency.State != domain.CIConcurrencyHolding { | |
| 932 | + | return false | |
| 933 | + | } | |
| 934 | + | return call.Call == nil || call.Call.Concurrency.Group == "" || call.Call.Concurrency.State == domain.CIConcurrencyHolding | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | // admitCalls lets the calls of a matrix start while the max-parallel of the | |
| 938 | + | // calling job's strategy allows it, in the order of the legs, as promote does | |
| 939 | + | // for the jobs that run. A call that is admitted asks for its groups and has its | |
| 940 | + | // jobs planned; one that is not waits, and starts nothing. | |
| 941 | + | func (rs *runState) admitCalls() bool { | |
| 942 | + | running := map[string]int{} | |
| 943 | + | for _, j := range rs.jobs { | |
| 944 | + | if j.Call != nil && j.Call.Admitted && !j.Ended() { | |
| 945 | + | running[j.Scope+"\x00"+j.Key]++ | |
| 946 | + | } | |
| 947 | + | } | |
| 948 | + | changed := false | |
| 949 | + | for i := range rs.jobs { | |
| 950 | + | j := &rs.jobs[i] | |
| 951 | + | if j.Call == nil || j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting { | |
| 952 | + | continue | |
| 953 | + | } | |
| 954 | + | k := j.Scope + "\x00" + j.Key | |
| 955 | + | if j.MaxParallel > 0 && running[k] >= j.MaxParallel { | |
| 956 | + | continue | |
| 957 | + | } | |
| 958 | + | call := *j.Call | |
| 959 | + | call.Admitted = true | |
| 960 | + | j.Call, changed = &call, true | |
| 961 | + | running[k]++ | |
| 962 | + | rs.touch(j) | |
| 963 | + | } | |
| 964 | + | return changed | |
| 965 | + | } | |
| 966 | + | ||
| 967 | + | // checkJobBudget refuses an expansion that would give the run more job records | |
| 968 | + | // than a commit may have checks (MaxCIChecks: each record has one check, and the | |
| 969 | + | // merge rule cannot read more). extra is the number of records the expansion | |
| 970 | + | // adds (the record of the job being expanded becomes the first of its legs). | |
| 971 | + | // GitHub's text is the runner's (JobCountValidator.cs); its limit applies to the | |
| 972 | + | // jobs across all referenced files, and the matrix of one job is capped at 256 | |
| 973 | + | // by ermite-actions (ErrTooManyConfigurations). | |
| 974 | + | func (rs *runState) checkJobBudget(extra int) error { | |
| 975 | + | if len(rs.jobs)+extra > MaxCIChecks { | |
| 976 | + | return callFailure{fmt.Sprintf("Workflows may not contain more than %d jobs across all referenced files", MaxCIChecks)} | |
| 977 | + | } | |
| 978 | + | return nil | |
| 979 | + | } | |
| 980 | + | ||
| 981 | + | // holdCalls asks for the turn of the calls that wait for a group: the group of | |
| 982 | + | // the calling job first, then the one of the called workflow, so that a call | |
| 983 | + | // waits in one group at a time. The call record is the entry, and holds both | |
| 984 | + | // until it ends (releaseEnded). | |
| 985 | + | func (rs *runState) holdCalls(ctx context.Context) (bool, error) { | |
| 986 | + | changed := false | |
| 987 | + | for i := range rs.jobs { | |
| 988 | + | j := &rs.jobs[i] | |
| 989 | + | if j.Call == nil || !j.Call.Expanded || !j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting { | |
| 990 | + | continue | |
| 991 | + | } | |
| 992 | + | entry := domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID} | |
| 993 | + | if g := j.Concurrency; g.Group != "" && !g.FromRun && g.State != domain.CIConcurrencyHolding { | |
| 994 | + | e := entry | |
| 995 | + | e.Cancel = g.Cancel | |
| 996 | + | st, err := rs.acquire(ctx, g.Group, e) | |
| 997 | + | if err != nil { | |
| 998 | + | return changed, err | |
| 999 | + | } | |
| 1000 | + | if st != g.State { | |
| 1001 | + | j.Concurrency.State, changed = st, true | |
| 1002 | + | rs.touch(j) | |
| 1003 | + | } | |
| 1004 | + | if st != domain.CIConcurrencyHolding { | |
| 1005 | + | continue | |
| 1006 | + | } | |
| 1007 | + | } | |
| 1008 | + | if g := j.Call.Concurrency; g.Group != "" && g.State != domain.CIConcurrencyHolding { | |
| 1009 | + | e := entry | |
| 1010 | + | e.Cancel = g.Cancel | |
| 1011 | + | st, err := rs.acquire(ctx, g.Group, e) | |
| 1012 | + | if err != nil { | |
| 1013 | + | return changed, err | |
| 1014 | + | } | |
| 1015 | + | if st != g.State { | |
| 1016 | + | call := *j.Call | |
| 1017 | + | call.Concurrency.State = st | |
| 1018 | + | j.Call, changed = &call, true | |
| 1019 | + | rs.touch(j) | |
| 1020 | + | } | |
| 1021 | + | } | |
| 1022 | + | } | |
| 1023 | + | return changed, nil | |
| 1024 | + | } | |
| 1025 | + | ||
| 1026 | + | // advanceCalls plans the jobs of called workflows whose needs ended and ends | |
| 1027 | + | // the call records whose jobs have. It returns at the first change, since a | |
| 1028 | + | // change reorders the records. | |
| 1029 | + | func (rs *runState) advanceCalls(ctx context.Context) (bool, error) { | |
| 1030 | + | if rs.admitCalls() { | |
| 1031 | + | return true, nil | |
| 1032 | + | } | |
| 1033 | + | if changed, err := rs.holdCalls(ctx); err != nil || changed { | |
| 1034 | + | return changed, err | |
| 1035 | + | } | |
| 1036 | + | for i := range rs.jobs { | |
| 1037 | + | rec := rs.jobs[i] | |
| 1038 | + | if rec.Scope == "" || rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting { | |
| 1039 | + | continue | |
| 1040 | + | } | |
| 1041 | + | call := rs.scopeOf(rec) | |
| 1042 | + | if call == nil { | |
| 1043 | + | rs.end(&rs.jobs[i], domain.CIFailure, "the call of this job is gone") | |
| 1044 | + | return true, nil | |
| 1045 | + | } | |
| 1046 | + | if call.Ended() { | |
| 1047 | + | rs.end(&rs.jobs[i], domain.CICancelled, "") | |
| 1048 | + | return true, nil | |
| 1049 | + | } | |
| 1050 | + | if call.Call == nil || !call.Call.Admitted { | |
| 1051 | + | continue // the max-parallel of the calling job holds the call back | |
| 1052 | + | } | |
| 1053 | + | if !callHeld(call) { | |
| 1054 | + | // Waits for the group of its call: shown as pending, as the jobs | |
| 1055 | + | // of a run behind a workflow-level group. | |
| 1056 | + | if rs.run.Concurrency.Group == "" && rec.Concurrency.State != domain.CIConcurrencyPending { | |
| 1057 | + | g := call.Concurrency | |
| 1058 | + | if g.Group == "" || g.State == domain.CIConcurrencyHolding { | |
| 1059 | + | g = call.Call.Concurrency | |
| 1060 | + | } | |
| 1061 | + | rs.jobs[i].Concurrency = domain.CIConcurrency{Group: g.Group, State: domain.CIConcurrencyPending, FromRun: true, Evaluated: true} | |
| 1062 | + | rs.touch(&rs.jobs[i]) | |
| 1063 | + | return true, nil | |
| 1064 | + | } | |
| 1065 | + | continue | |
| 1066 | + | } | |
| 1067 | + | if rec.Concurrency.FromRun && rs.run.Concurrency.Group == "" { | |
| 1068 | + | rs.jobs[i].Concurrency = domain.CIConcurrency{} | |
| 1069 | + | rs.touch(&rs.jobs[i]) | |
| 1070 | + | } | |
| 1071 | + | cw, err := rs.calleeOf(call) | |
| 1072 | + | if err != nil { | |
| 1073 | + | rs.end(&rs.jobs[i], domain.CIFailure, err.Error()) | |
| 1074 | + | return true, nil | |
| 1075 | + | } | |
| 1076 | + | cj := cw.Job(rec.CallID) | |
| 1077 | + | if cj == nil { | |
| 1078 | + | rs.end(&rs.jobs[i], domain.CIFailure, "the called workflow has no job '"+rec.CallID+"'") | |
| 1079 | + | return true, nil | |
| 1080 | + | } | |
| 1081 | + | if len(rs.instancesIn(rec.Scope, rec.CallID)) != 1 { | |
| 1082 | + | continue | |
| 1083 | + | } | |
| 1084 | + | needs, st, ready := rs.needsOf(cw, cj, rec.Scope) | |
| 1085 | + | if !ready { | |
| 1086 | + | continue | |
| 1087 | + | } | |
| 1088 | + | rs.planJob(ctx, cw, cj, i, needs, st) | |
| 1089 | + | return true, nil | |
| 1090 | + | } | |
| 1091 | + | for i := range rs.jobs { | |
| 1092 | + | call := &rs.jobs[i] | |
| 1093 | + | if call.Call == nil || !call.Call.Expanded { | |
| 1094 | + | continue | |
| 1095 | + | } | |
| 1096 | + | var scoped []int | |
| 1097 | + | allEnded := true | |
| 1098 | + | for k, j := range rs.jobs { | |
| 1099 | + | if j.Scope == call.ID { | |
| 1100 | + | scoped = append(scoped, k) | |
| 1101 | + | allEnded = allEnded && j.Ended() | |
| 1102 | + | } | |
| 1103 | + | } | |
| 1104 | + | if call.Ended() { | |
| 1105 | + | changed := false | |
| 1106 | + | for _, k := range scoped { | |
| 1107 | + | s := &rs.jobs[k] | |
| 1108 | + | switch { | |
| 1109 | + | case s.Ended(): | |
| 1110 | + | case s.Active(): | |
| 1111 | + | rs.requestCancel(s, domain.CICancelUser, endedCallMessage(*call)) | |
| 1112 | + | default: | |
| 1113 | + | rs.end(s, domain.CICancelled, "") | |
| 1114 | + | changed = true | |
| 1115 | + | } | |
| 1116 | + | } | |
| 1117 | + | if changed { | |
| 1118 | + | return true, nil | |
| 1119 | + | } | |
| 1120 | + | continue | |
| 1121 | + | } | |
| 1122 | + | if allEnded { | |
| 1123 | + | rs.finishCall(ctx, i, scoped) | |
| 1124 | + | return true, nil | |
| 1125 | + | } | |
| 1126 | + | } | |
| 1127 | + | return false, nil | |
| 1128 | + | } | |
| 1129 | + | ||
| 1130 | + | // finishCall ends a call record with the conclusion of its jobs and the outputs | |
| 1131 | + | // of the called workflow. | |
| 1132 | + | func (rs *runState) finishCall(ctx context.Context, idx int, scoped []int) { | |
| 1133 | + | call := &rs.jobs[idx] | |
| 1134 | + | var top []domain.CIJob | |
| 1135 | + | for _, k := range scoped { | |
| 1136 | + | top = append(top, rs.jobs[k]) | |
| 1137 | + | } | |
| 1138 | + | conc, msg := domain.CISuccess, "" | |
| 1139 | + | switch result(top) { | |
| 1140 | + | case "failure": | |
| 1141 | + | conc = domain.CIFailure | |
| 1142 | + | msg = "A job of the called workflow failed" | |
| 1143 | + | for _, j := range top { | |
| 1144 | + | if j.Conclusion.IsFailure() && !j.ContinueOnError { | |
| 1145 | + | msg = "The job '" + j.Name + "' of the called workflow failed" | |
| 1146 | + | break | |
| 1147 | + | } | |
| 1148 | + | } | |
| 1149 | + | case "cancelled": | |
| 1150 | + | conc = domain.CICancelled | |
| 1151 | + | case "skipped": | |
| 1152 | + | conc = domain.CISkipped | |
| 1153 | + | } | |
| 1154 | + | jobs := expr.NewDictionary() | |
| 1155 | + | for _, id := range distinctCallIDs(top) { | |
| 1156 | + | var insts []domain.CIJob | |
| 1157 | + | for _, j := range top { | |
| 1158 | + | if j.CallID == id { | |
| 1159 | + | insts = append(insts, j) | |
| 1160 | + | } | |
| 1161 | + | } | |
| 1162 | + | outs := expr.NewDictionary() | |
| 1163 | + | for _, in := range insts { | |
| 1164 | + | for _, k := range sortedKeys(in.Outputs) { | |
| 1165 | + | if v := in.Outputs[k]; v != "" || !hasKey(outs, k) { | |
| 1166 | + | setKey(outs, k, expr.String(v)) | |
| 1167 | + | } | |
| 1168 | + | } | |
| 1169 | + | } | |
| 1170 | + | d := expr.NewDictionary() | |
| 1171 | + | d.Add("result", expr.String(result(insts))) | |
| 1172 | + | d.Add("outputs", outs) | |
| 1173 | + | jobs.Add(id, d) | |
| 1174 | + | } | |
| 1175 | + | outputs := map[string]string{} | |
| 1176 | + | spec := readCalleeSpec([]byte(call.Call.YAML)) | |
| 1177 | + | if len(spec.outputs) > 0 { | |
| 1178 | + | gh, err := rs.githubValue(call.Key, rs.run.Attempt) | |
| 1179 | + | vars, verr := rs.c.varsContext(ctx, rs.run.Repo) | |
| 1180 | + | if err == nil && verr == nil { | |
| 1181 | + | env := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars, "jobs": jobs}} | |
| 1182 | + | // The inputs of the call itself. | |
| 1183 | + | if v, err := expr.FromJSON([]byte(call.Call.InputsJSON)); err == nil { | |
| 1184 | + | env.Contexts["inputs"] = v | |
| 1185 | + | } | |
| 1186 | + | for _, name := range sortedKeys(spec.outputs) { | |
| 1187 | + | v, err := evalInterpolated(ctx, spec.outputs[name], env) | |
| 1188 | + | if err != nil { | |
| 1189 | + | ctxLog(ctx).Warn().Str("output", name).Msg("ci: an output of a called workflow could not evaluate") | |
| 1190 | + | continue | |
| 1191 | + | } | |
| 1192 | + | outputs[name] = expr.CoerceString(v) | |
| 1193 | + | } | |
| 1194 | + | } | |
| 1195 | + | } | |
| 1196 | + | call.Outputs = outputs | |
| 1197 | + | rs.end(call, conc, msg) | |
| 1198 | + | } | |
| 1199 | + | ||
| 1200 | + | func distinctCallIDs(js []domain.CIJob) []string { | |
| 1201 | + | var out []string | |
| 1202 | + | for _, j := range js { | |
| 1203 | + | if !slices.Contains(out, j.CallID) { | |
| 1204 | + | out = append(out, j.CallID) | |
| 1205 | + | } | |
| 1206 | + | } | |
| 1207 | + | return out | |
| 1208 | + | } | |
| 1209 | + | ||
| 1210 | + | // parseCalled parses a called workflow, once per distinct file. | |
| 1211 | + | func (w *wfCache) parseCalled(path, yamlText string) (*workflow.Workflow, error) { | |
| 1212 | + | sum := sha256.Sum256([]byte(path + "\x00" + yamlText)) | |
| 1213 | + | key := hex.EncodeToString(sum[:]) | |
| 1214 | + | w.mu.Lock() | |
| 1215 | + | if wf, ok := w.called[key]; ok { | |
| 1216 | + | w.mu.Unlock() | |
| 1217 | + | return wf, nil | |
| 1218 | + | } | |
| 1219 | + | w.mu.Unlock() | |
| 1220 | + | wf, errs := workflow.Parse(path, []byte(yamlText)) | |
| 1221 | + | if len(errs) > 0 { | |
| 1222 | + | return nil, fmt.Errorf("the called workflow no longer parses: %s", errs[0].Error()) | |
| 1223 | + | } | |
| 1224 | + | w.mu.Lock() | |
| 1225 | + | defer w.mu.Unlock() | |
| 1226 | + | if len(w.called) >= w.max { | |
| 1227 | + | clear(w.called) | |
| 1228 | + | } | |
| 1229 | + | w.called[key] = wf | |
| 1230 | + | return wf, nil | |
| 1231 | + | } | |
| 1232 | + | ||
| 1233 | + | // jobKeysOf are the keys of the "job" context the server knows. A job of a | |
| 1234 | + | // called workflow names the called workflow in them (workflow_ref, | |
| 1235 | + | // workflow_sha, workflow_repository, workflow_file_path); github.workflow_ref | |
| 1236 | + | // and github.workflow_sha stay the caller's (the github context is the | |
| 1237 | + | // caller's). | |
| 1238 | + | func (rs *runState) jobKeysOf(ctx context.Context, base domain.CIJob, combo int) ordered { | |
| 1239 | + | keys := rs.c.jobKeys(ctx, rs.run, base.Index, combo) | |
| 1240 | + | call := rs.scopeOf(base) | |
| 1241 | + | if call == nil || call.Call == nil { | |
| 1242 | + | return keys | |
| 1243 | + | } | |
| 1244 | + | c := call.Call | |
| 1245 | + | full := c.Repository | |
| 1246 | + | if !strings.Contains(full, "/") { | |
| 1247 | + | full = rs.c.cfg.Owner + "/" + full | |
| 1248 | + | } | |
| 1249 | + | ref := c.Ref | |
| 1250 | + | if ref == "" { | |
| 1251 | + | ref = rs.run.Ref | |
| 1252 | + | } | |
| 1253 | + | return ordered{ | |
| 1254 | + | keys[0], {"workflow_ref", full + "/" + c.Path + "@" + ref}, {"workflow_sha", c.Sha}, | |
| 1255 | + | {"workflow_repository", full}, {"workflow_file_path", c.Path}, | |
| 1256 | + | } | |
| 1257 | + | } | |
| 1258 | + | ||
| 1259 | + | // endedCallMessage is what the jobs of a call that ended are told: the reason | |
| 1260 | + | // the call was cancelled for (a newer call of its concurrency group), else that | |
| 1261 | + | // the calling job ended. | |
| 1262 | + | func endedCallMessage(call domain.CIJob) string { | |
| 1263 | + | if call.Concurrency.Preempted || call.Error != "" { | |
| 1264 | + | return call.Error | |
| 1265 | + | } | |
| 1266 | + | return "Canceled because the calling job ended" | |
| 1267 | + | } | |
| 1268 | + | ||
| 1269 | + | // callResolutions copies the resolutions of the reusable workflows that the | |
| 1270 | + | // attempt of rs called to the attempt next. | |
| 1271 | + | func (c *CI) callResolutions(ctx context.Context, rs *runState, next int32) ([]domain.CIActionResolution, error) { | |
| 1272 | + | var out []domain.CIActionResolution | |
| 1273 | + | seen := map[string]bool{} | |
| 1274 | + | for _, j := range rs.jobs { | |
| 1275 | + | if j.Call == nil { | |
| 1276 | + | continue | |
| 1277 | + | } | |
| 1278 | + | ref, text := parseCallRef(j.Call.Uses) | |
| 1279 | + | if text != "" || ref.local { | |
| 1280 | + | continue | |
| 1281 | + | } | |
| 1282 | + | key := actionRef{owner: ref.owner, repo: ref.repo, ref: ref.ref}.key() | |
| 1283 | + | if seen[key] { | |
| 1284 | + | continue | |
| 1285 | + | } | |
| 1286 | + | seen[key] = true | |
| 1287 | + | res, err := c.cfg.Store.GetActionResolution(ctx, rs.run.ID, rs.attempt, key) | |
| 1288 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 1289 | + | continue | |
| 1290 | + | } | |
| 1291 | + | if err != nil { | |
| 1292 | + | return nil, err | |
| 1293 | + | } | |
| 1294 | + | res.RunAttempt = next | |
| 1295 | + | out = append(out, res) | |
| 1296 | + | } | |
| 1297 | + | return out, nil | |
| 1298 | + | } | |
| @@ -0,0 +1,1298 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "crypto/sha256" | |||
| 8 | + | "encoding/hex" | |||
| 9 | + | "encoding/json" | |||
| 10 | + | "errors" | |||
| 11 | + | "fmt" | |||
| 12 | + | "io" | |||
| 13 | + | "slices" | |||
| 14 | + | "strconv" | |||
| 15 | + | "strings" | |||
| 16 | + | ||||
| 17 | + | "go.yaml.in/yaml/v3" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite-actions/expr" | |||
| 20 | + | "bnema.dev/ermite-actions/template" | |||
| 21 | + | "bnema.dev/ermite-actions/workflow" | |||
| 22 | + | "bnema.dev/ermite/internal/domain" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // Reusable workflows (CI-3, D7). ermite owns the job graph, so a job with | |||
| 26 | + | // "uses:" is expanded at planning, not by the runner: | |||
| 27 | + | // | |||
| 28 | + | // - the job becomes a call record (one per matrix leg) that never runs; it ends | |||
| 29 | + | // when the jobs of its scope have, with their aggregate conclusion, and its | |||
| 30 | + | // outputs are the callee's "on.workflow_call.outputs"; | |||
| 31 | + | // - each job of the called workflow becomes a record of that scope, keyed | |||
| 32 | + | // "<caller key>/<callee id>", named "<caller name> / <callee name>", planned | |||
| 33 | + | // when the callee's own "needs" have ended (the caller's "needs" ended | |||
| 34 | + | // before the call was expanded, so they hold for every callee root); | |||
| 35 | + | // - the called file is kept in the call record, so every job of the call runs | |||
| 36 | + | // from the file of the commit that was resolved, and the runner is given that | |||
| 37 | + | // file and the callee job id. | |||
| 38 | + | // | |||
| 39 | + | // Texts are the ones of actions/runner (ReusableWorkflowsLoader.cs, | |||
| 40 | + | // WorkflowTemplateConverter.cs, PermissionsHelper.cs) and languageservices | |||
| 41 | + | // (validate.ts) where they exist. | |||
| 42 | + | ||||
| 43 | + | // maxReusableWorkflowDepth is the nesting limit of reusable workflows | |||
| 44 | + | // (languageservices workflow-parser/src/model/convert.ts:49, 4; the runner's | |||
| 45 | + | // check is ReusableWorkflowsLoader.LoadRecursive). | |||
| 46 | + | const maxReusableWorkflowDepth = 4 | |||
| 47 | + | ||||
| 48 | + | // maxJobIndex is the highest job position a check id can hold (checkIndexBits). | |||
| 49 | + | const maxJobIndex = 1<<checkIndexBits - 1 | |||
| 50 | + | ||||
| 51 | + | // callRef is a parsed "uses:" of a job. | |||
| 52 | + | type callRef struct { | |||
| 53 | + | local bool | |||
| 54 | + | owner, repo string | |||
| 55 | + | path string // .github/workflows/x.yml | |||
| 56 | + | ref string | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // parseCallRef reads the "uses:" of a job calling a workflow. The texts are | |||
| 60 | + | // languageservices' (validate.ts validateWorkflowUsesFormat). | |||
| 61 | + | func parseCallRef(uses string) (callRef, string) { | |||
| 62 | + | bad := func(reason string) (callRef, string) { | |||
| 63 | + | return callRef{}, "Invalid workflow reference '" + uses + "': " + reason | |||
| 64 | + | } | |||
| 65 | + | if strings.HasPrefix(uses, "./.github/workflows/") { | |||
| 66 | + | switch { | |||
| 67 | + | case strings.Contains(uses, "@"): | |||
| 68 | + | return bad("cannot specify version when calling local workflows") | |||
| 69 | + | case !strings.HasSuffix(uses, ".yml") && !strings.HasSuffix(uses, ".yaml"): | |||
| 70 | + | return bad("workflow file should have either a '.yml' or '.yaml' file extension") | |||
| 71 | + | case len(strings.Split(uses, "/")) != 4: | |||
| 72 | + | return bad("workflows must be defined at the top level of the .github/workflows/ directory") | |||
| 73 | + | case strings.HasSuffix(uses, "/.yml") || strings.HasSuffix(uses, "/.yaml"): | |||
| 74 | + | return bad("invalid workflow file name") | |||
| 75 | + | } | |||
| 76 | + | return callRef{local: true, path: strings.TrimPrefix(uses, "./")}, "" | |||
| 77 | + | } | |||
| 78 | + | if strings.HasPrefix(uses, "./") { | |||
| 79 | + | return bad("local workflow references must be rooted in '.github/workflows'") | |||
| 80 | + | } | |||
| 81 | + | parts := strings.Split(uses, "@") | |||
| 82 | + | switch { | |||
| 83 | + | case len(parts) == 1 || (len(parts) == 2 && parts[1] == ""): | |||
| 84 | + | return bad("no version specified") | |||
| 85 | + | case len(parts) > 2: | |||
| 86 | + | return bad("too many '@' in workflow reference") | |||
| 87 | + | } | |||
| 88 | + | i := strings.Index(parts[0], ".github/workflows/") | |||
| 89 | + | if i < 0 { | |||
| 90 | + | return bad("references to workflows must be rooted in '.github/workflows'") | |||
| 91 | + | } | |||
| 92 | + | nwo := strings.FieldsFunc(parts[0][:i], func(r rune) bool { return r == '/' }) | |||
| 93 | + | if len(nwo) != 2 { | |||
| 94 | + | return bad("references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows") | |||
| 95 | + | } | |||
| 96 | + | if !validActionName(nwo[0]) || !validActionName(nwo[1]) { | |||
| 97 | + | return bad("owner name must be a valid repository owner name") | |||
| 98 | + | } | |||
| 99 | + | path := parts[0][i:] | |||
| 100 | + | for _, s := range strings.Split(path, "/") { | |||
| 101 | + | if s == "" || s == "." || s == ".." { | |||
| 102 | + | return bad("references to workflows must be rooted in '.github/workflows'") | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | return callRef{owner: nwo[0], repo: nwo[1], path: path, ref: parts[1]}, "" | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // callFailure is a call that cannot be expanded; text is the job's error. | |||
| 109 | + | type callFailure struct{ text string } | |||
| 110 | + | ||||
| 111 | + | func (e callFailure) Error() string { return e.text } | |||
| 112 | + | ||||
| 113 | + | func isCallFailure(err error) (string, bool) { | |||
| 114 | + | var cf callFailure | |||
| 115 | + | if errors.As(err, &cf) { | |||
| 116 | + | return cf.text, true | |||
| 117 | + | } | |||
| 118 | + | return "", false | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // calledFile is a workflow file read for a call. | |||
| 122 | + | type calledFile struct { | |||
| 123 | + | repository, path, ref, sha string | |||
| 124 | + | yaml []byte | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // loadCallee finds the file a "uses:" names. home is the call whose file holds | |||
| 128 | + | // the "uses:" (nil for the run's own workflow): a local reference is read in | |||
| 129 | + | // the repository and at the commit of that file. | |||
| 130 | + | func (c *CI) loadCallee(ctx context.Context, rs *runState, home *domain.CICall, rec domain.CIJob, ref callRef) (calledFile, error) { | |||
| 131 | + | var f calledFile | |||
| 132 | + | if ref.local { | |||
| 133 | + | f.path = ref.path | |||
| 134 | + | if home == nil { | |||
| 135 | + | f.repository, f.sha = rs.run.Repo.String(), rs.run.Sha | |||
| 136 | + | } else { | |||
| 137 | + | f.repository, f.sha = home.Repository, home.Sha | |||
| 138 | + | } | |||
| 139 | + | return f, c.readCallee(ctx, &f) | |||
| 140 | + | } | |||
| 141 | + | f.path, f.ref = ref.path, ref.ref | |||
| 142 | + | ar := actionRef{uses: ref.owner + "/" + ref.repo + "@" + ref.ref, owner: ref.owner, repo: ref.repo, ref: ref.ref} | |||
| 143 | + | by := c.onBehalfOf(*rs.src) | |||
| 144 | + | // One resolution per reference and attempt: what this planning resolved | |||
| 145 | + | // already (not stored yet), else what the attempt stored, else a fresh one. | |||
| 146 | + | var res domain.CIActionResolution | |||
| 147 | + | found := false | |||
| 148 | + | for _, r := range rs.resolutions { | |||
| 149 | + | if r.RunID == rec.RunID && r.RunAttempt == rec.RunAttempt && r.Key == ar.key() { | |||
| 150 | + | res, found = r, true | |||
| 151 | + | break | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | if !found { | |||
| 155 | + | stored, err := c.cfg.Store.GetActionResolution(ctx, rec.RunID, rec.RunAttempt, ar.key()) | |||
| 156 | + | switch { | |||
| 157 | + | case err == nil: | |||
| 158 | + | res = stored | |||
| 159 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 160 | + | var msg string | |||
| 161 | + | res, msg, err = c.resolveAction(ctx, rec, ar, newRemoteRefs(c.cfg.Remote, c.remoteSlots), by) | |||
| 162 | + | if err != nil { | |||
| 163 | + | return f, err | |||
| 164 | + | } | |||
| 165 | + | if msg != "" { | |||
| 166 | + | return f, callFailure{msg} | |||
| 167 | + | } | |||
| 168 | + | rs.resolutions = append(rs.resolutions, res) | |||
| 169 | + | default: | |||
| 170 | + | return f, err | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | // The source guard of ResolveActions (answer), whatever the resolution came | |||
| 174 | + | // from: a repository of this server that the actor cannot read is never | |||
| 175 | + | // served from anywhere else, and a stored ermite resolution of a repository | |||
| 176 | + | // that is gone has nothing to serve. | |||
| 177 | + | repo, onErmite, err := c.ermiteRepoOf(ctx, ar) | |||
| 178 | + | if err != nil { | |||
| 179 | + | return f, err | |||
| 180 | + | } | |||
| 181 | + | if onErmite { | |||
| 182 | + | if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil { | |||
| 183 | + | return f, err | |||
| 184 | + | } else if !ok { | |||
| 185 | + | return f, callFailure{errUnresolvedRepo(ar)} | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | f.sha = res.ResolvedSHA | |||
| 189 | + | switch res.Source { | |||
| 190 | + | case domain.CIActionErmite: | |||
| 191 | + | if !onErmite { | |||
| 192 | + | return f, callFailure{errUnresolvedRepo(ar)} | |||
| 193 | + | } | |||
| 194 | + | f.repository = repo.Name.String() | |||
| 195 | + | default: | |||
| 196 | + | f.repository = ref.owner + "/" + ref.repo | |||
| 197 | + | } | |||
| 198 | + | return f, c.readCallee(ctx, &f) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // readCallee fills f.yaml from the repository of the server or github.com. | |||
| 202 | + | func (c *CI) readCallee(ctx context.Context, f *calledFile) error { | |||
| 203 | + | notFound := callFailure{"Unable to find reusable workflow '" + f.path + "' in '" + f.repository + "' at '" + f.sha + "'"} | |||
| 204 | + | if owner, repo, ok := strings.Cut(f.repository, "/"); ok { | |||
| 205 | + | if c.cfg.Workflows == nil { | |||
| 206 | + | return fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable) | |||
| 207 | + | } | |||
| 208 | + | data, err := c.cfg.Workflows.FetchWorkflow(ctx, owner, repo, f.sha, f.path, maxCIWorkflowBytes) | |||
| 209 | + | switch { | |||
| 210 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 211 | + | return notFound | |||
| 212 | + | case errors.Is(err, domain.ErrResourceExhausted): | |||
| 213 | + | return callFailure{f.path + ": the workflow file is larger than 1 MiB"} | |||
| 214 | + | case err != nil: | |||
| 215 | + | return err | |||
| 216 | + | } | |||
| 217 | + | f.yaml = data | |||
| 218 | + | return nil | |||
| 219 | + | } | |||
| 220 | + | name, err := domain.ParseRepoName(f.repository) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return notFound | |||
| 223 | + | } | |||
| 224 | + | id, err := domain.ParseObjectID(f.sha) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return notFound | |||
| 227 | + | } | |||
| 228 | + | tp, err := domain.ParseTreePath(f.path) | |||
| 229 | + | if err != nil { | |||
| 230 | + | return notFound | |||
| 231 | + | } | |||
| 232 | + | _, rc, err := c.cfg.Reader.OpenBlob(ctx, name, domain.RevisionOf(id), tp) | |||
| 233 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 234 | + | return notFound | |||
| 235 | + | } | |||
| 236 | + | if err != nil { | |||
| 237 | + | return err | |||
| 238 | + | } | |||
| 239 | + | defer func() { _ = rc.Close() }() | |||
| 240 | + | data, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1)) | |||
| 241 | + | if err != nil { | |||
| 242 | + | return err | |||
| 243 | + | } | |||
| 244 | + | if len(data) > maxCIWorkflowBytes { | |||
| 245 | + | return callFailure{f.path + ": the workflow file is larger than 1 MiB"} | |||
| 246 | + | } | |||
| 247 | + | f.yaml = data | |||
| 248 | + | return nil | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // callerPrefix is the text GitHub puts before an error of the called workflow | |||
| 252 | + | // (TemplateStrings: CalledWorkflowNotValidWithErrors, LineColumn). | |||
| 253 | + | func callerPrefix(wj *workflow.Job) string { | |||
| 254 | + | file, pos := "", template.Pos{} | |||
| 255 | + | if wj.Uses != nil { | |||
| 256 | + | file, pos = wj.Uses.File(), wj.Uses.Pos() | |||
| 257 | + | } | |||
| 258 | + | return fmt.Sprintf("In %s (Line: %d, Col: %d): Error from called workflow", file, pos.Line, pos.Col) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // calleeSpec is what ermite reads of "on.workflow_call" that the workflow model | |||
| 262 | + | // does not keep: the outputs and the secrets (ermite-actions parses the inputs). | |||
| 263 | + | type calleeSpec struct { | |||
| 264 | + | outputs map[string]string | |||
| 265 | + | secrets map[string]bool // name (upper case) to required | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | // readCalleeSpec reads on.workflow_call.outputs and .secrets from the YAML. | |||
| 269 | + | func readCalleeSpec(src []byte) calleeSpec { | |||
| 270 | + | spec := calleeSpec{outputs: map[string]string{}, secrets: map[string]bool{}} | |||
| 271 | + | var doc yaml.Node | |||
| 272 | + | if yaml.Unmarshal(src, &doc) != nil || len(doc.Content) == 0 { | |||
| 273 | + | return spec | |||
| 274 | + | } | |||
| 275 | + | root := derefYAML(doc.Content[0]) | |||
| 276 | + | for i := 0; root.Kind == yaml.MappingNode && i+1 < len(root.Content); i += 2 { | |||
| 277 | + | if root.Content[i].Value != "on" { | |||
| 278 | + | continue | |||
| 279 | + | } | |||
| 280 | + | on := derefYAML(root.Content[i+1]) | |||
| 281 | + | for j := 0; on.Kind == yaml.MappingNode && j+1 < len(on.Content); j += 2 { | |||
| 282 | + | if on.Content[j].Value != "workflow_call" { | |||
| 283 | + | continue | |||
| 284 | + | } | |||
| 285 | + | wc := derefYAML(on.Content[j+1]) | |||
| 286 | + | for k := 0; wc.Kind == yaml.MappingNode && k+1 < len(wc.Content); k += 2 { | |||
| 287 | + | body := derefYAML(wc.Content[k+1]) | |||
| 288 | + | for m := 0; body.Kind == yaml.MappingNode && m+1 < len(body.Content); m += 2 { | |||
| 289 | + | name, def := body.Content[m].Value, derefYAML(body.Content[m+1]) | |||
| 290 | + | switch wc.Content[k].Value { | |||
| 291 | + | case "outputs": | |||
| 292 | + | for q := 0; def.Kind == yaml.MappingNode && q+1 < len(def.Content); q += 2 { | |||
| 293 | + | if def.Content[q].Value == "value" { | |||
| 294 | + | spec.outputs[name] = derefYAML(def.Content[q+1]).Value | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | case "secrets": | |||
| 298 | + | req := false | |||
| 299 | + | for q := 0; def.Kind == yaml.MappingNode && q+1 < len(def.Content); q += 2 { | |||
| 300 | + | if def.Content[q].Value == "required" { | |||
| 301 | + | req = derefYAML(def.Content[q+1]).Value == "true" | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | spec.secrets[strings.ToUpper(name)] = req | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | return spec | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func derefYAML(n *yaml.Node) *yaml.Node { | |||
| 314 | + | for range 16 { | |||
| 315 | + | if n.Kind != yaml.AliasNode || n.Alias == nil { | |||
| 316 | + | return n | |||
| 317 | + | } | |||
| 318 | + | n = n.Alias | |||
| 319 | + | } | |||
| 320 | + | return n | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // callInputs checks the "with:" of a call against the inputs of the called | |||
| 324 | + | // workflow and returns the inputs context: the value given, else the default, | |||
| 325 | + | // else the zero of the type. Texts: WorkflowTemplateConverter.cs | |||
| 326 | + | // (ConvertToWorkflowJobInputs, ValidateUndefinedParameters). | |||
| 327 | + | func callInputs(ctx context.Context, defs []workflow.Input, given *expr.Dictionary, defEnv template.Env) (*expr.Dictionary, error) { | |||
| 328 | + | out := expr.NewDictionary() | |||
| 329 | + | byName := map[string]bool{} | |||
| 330 | + | for _, in := range defs { | |||
| 331 | + | byName[strings.ToLower(in.Name)] = true | |||
| 332 | + | var v expr.Value | |||
| 333 | + | have := false | |||
| 334 | + | if given != nil { | |||
| 335 | + | for _, p := range given.Pairs() { | |||
| 336 | + | if strings.EqualFold(p.Key, in.Name) { | |||
| 337 | + | cv, err := coerceInput(in, p.Value) | |||
| 338 | + | if err != nil { | |||
| 339 | + | return nil, err | |||
| 340 | + | } | |||
| 341 | + | v, have = cv, true | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | if !have && in.HasDefault { | |||
| 346 | + | dv, err := evalInterpolated(ctx, in.Default, defEnv) | |||
| 347 | + | if err != nil { | |||
| 348 | + | return nil, err | |||
| 349 | + | } | |||
| 350 | + | cv, err := coerceInput(in, dv) | |||
| 351 | + | if err != nil { | |||
| 352 | + | return nil, err | |||
| 353 | + | } | |||
| 354 | + | v, have = cv, true | |||
| 355 | + | } | |||
| 356 | + | if !have && in.Required { | |||
| 357 | + | return nil, callFailure{"Input " + in.Name + " is required, but not provided while calling."} | |||
| 358 | + | } | |||
| 359 | + | if !have { | |||
| 360 | + | switch in.Type { | |||
| 361 | + | case "boolean": | |||
| 362 | + | v = expr.Boolean(false) | |||
| 363 | + | case "number": | |||
| 364 | + | v = expr.Number(0) | |||
| 365 | + | default: | |||
| 366 | + | v = expr.String("") | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | out.Add(in.Name, v) | |||
| 370 | + | } | |||
| 371 | + | if given != nil { | |||
| 372 | + | for _, p := range given.Pairs() { | |||
| 373 | + | if !byName[strings.ToLower(p.Key)] { | |||
| 374 | + | return nil, callFailure{"Invalid input, " + p.Key + " is not defined in the referenced workflow."} | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | return out, nil | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // coerceInput converts a value to the type of an input, as the template engine | |||
| 382 | + | // does for a boolean, number or string (GetDefaultValueByType, | |||
| 383 | + | // ConvertToInputValueDefinedType): a mismatch is "Unexpected value". | |||
| 384 | + | func coerceInput(in workflow.Input, v expr.Value) (expr.Value, error) { | |||
| 385 | + | unexpected := func() (expr.Value, error) { | |||
| 386 | + | return nil, callFailure{"Unexpected value '" + expr.CoerceString(v) + "' for input '" + in.Name + "' of type '" + in.Type + "'"} | |||
| 387 | + | } | |||
| 388 | + | switch in.Type { | |||
| 389 | + | case "boolean": | |||
| 390 | + | switch x := v.(type) { | |||
| 391 | + | case expr.Boolean: | |||
| 392 | + | return x, nil | |||
| 393 | + | case expr.String: | |||
| 394 | + | switch strings.ToLower(string(x)) { | |||
| 395 | + | case "true": | |||
| 396 | + | return expr.Boolean(true), nil | |||
| 397 | + | case "false": | |||
| 398 | + | return expr.Boolean(false), nil | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | return unexpected() | |||
| 402 | + | case "number": | |||
| 403 | + | switch x := v.(type) { | |||
| 404 | + | case expr.Number: | |||
| 405 | + | return x, nil | |||
| 406 | + | case expr.String: | |||
| 407 | + | if f, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64); err == nil { | |||
| 408 | + | return expr.Number(f), nil | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | return unexpected() | |||
| 412 | + | } | |||
| 413 | + | switch v.(type) { | |||
| 414 | + | case expr.String, expr.Number, expr.Boolean: | |||
| 415 | + | if s, ok := v.(expr.String); ok { | |||
| 416 | + | return s, nil | |||
| 417 | + | } | |||
| 418 | + | return expr.String(expr.CoerceString(v)), nil | |||
| 419 | + | } | |||
| 420 | + | return unexpected() | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | // evalInterpolated evaluates a string with ${{ }} expressions (a default of an | |||
| 424 | + | // input, the value of an output). A string that is one expression keeps the | |||
| 425 | + | // type of its value. | |||
| 426 | + | func evalInterpolated(ctx context.Context, s string, env template.Env) (expr.Value, error) { | |||
| 427 | + | if !strings.Contains(s, "${{") { | |||
| 428 | + | return expr.String(s), nil | |||
| 429 | + | } | |||
| 430 | + | ctxNames := make([]string, 0, len(env.Contexts)) | |||
| 431 | + | for k := range env.Contexts { | |||
| 432 | + | ctxNames = append(ctxNames, k) | |||
| 433 | + | } | |||
| 434 | + | eval := func(src string) (expr.Value, error) { | |||
| 435 | + | node, err := expr.Parse(src, ctxNames, exprFuncs) | |||
| 436 | + | if err != nil { | |||
| 437 | + | return nil, err | |||
| 438 | + | } | |||
| 439 | + | return expr.Evaluate(ctx, node, expr.EvalContext{Contexts: env.Contexts}) | |||
| 440 | + | } | |||
| 441 | + | exprs := expressionsIn(s) | |||
| 442 | + | if t := strings.TrimSpace(s); len(exprs) == 1 && strings.HasPrefix(t, "${{") && strings.HasSuffix(t, "}}") && strings.Count(t, "${{") == 1 { | |||
| 443 | + | return eval(exprs[0]) | |||
| 444 | + | } | |||
| 445 | + | var b strings.Builder | |||
| 446 | + | rest := s | |||
| 447 | + | for _, src := range exprs { | |||
| 448 | + | i := strings.Index(rest, "${{") | |||
| 449 | + | b.WriteString(rest[:i]) | |||
| 450 | + | v, err := eval(src) | |||
| 451 | + | if err != nil { | |||
| 452 | + | return nil, err | |||
| 453 | + | } | |||
| 454 | + | b.WriteString(expr.CoerceString(v)) | |||
| 455 | + | rest = rest[i+3+len(src)+2:] | |||
| 456 | + | } | |||
| 457 | + | b.WriteString(rest) | |||
| 458 | + | return expr.String(b.String()), nil | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | // calleeOf parses the workflow a call record kept. | |||
| 462 | + | func (rs *runState) calleeOf(call *domain.CIJob) (*workflow.Workflow, error) { | |||
| 463 | + | if call == nil || call.Call == nil { | |||
| 464 | + | return nil, fmt.Errorf("the call of this job is gone") | |||
| 465 | + | } | |||
| 466 | + | return rs.c.flows.parseCalled(call.Call.Path, call.Call.YAML) | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | // scopeOf finds the call record of a scoped job, nil for a job of the run's own | |||
| 470 | + | // workflow. | |||
| 471 | + | func (rs *runState) scopeOf(j domain.CIJob) *domain.CIJob { | |||
| 472 | + | if j.Scope == "" { | |||
| 473 | + | return nil | |||
| 474 | + | } | |||
| 475 | + | return rs.find(j.Scope) | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | // inputsOf is the "inputs" context of a job: its call's inputs, empty at the | |||
| 479 | + | // top. | |||
| 480 | + | func (rs *runState) inputsOf(j domain.CIJob) *expr.Dictionary { | |||
| 481 | + | if sc := rs.scopeOf(j); sc != nil && sc.Call != nil { | |||
| 482 | + | if v, err := expr.FromJSON([]byte(sc.Call.InputsJSON)); err == nil { | |||
| 483 | + | if d, ok := v.(*expr.Dictionary); ok { | |||
| 484 | + | return d | |||
| 485 | + | } | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | return expr.NewDictionary() | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // instancesIn returns the indexes of the records of job id in a scope. | |||
| 492 | + | func (rs *runState) instancesIn(scope, id string) []int { | |||
| 493 | + | var out []int | |||
| 494 | + | for i, j := range rs.jobs { | |||
| 495 | + | if j.Scope == scope && ((scope == "" && j.Key == id) || (scope != "" && j.CallID == id)) { | |||
| 496 | + | out = append(out, i) | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | return out | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | // rootKey is the key of the job of the run's own workflow a record descends | |||
| 503 | + | // from. | |||
| 504 | + | func rootKey(key string) string { | |||
| 505 | + | root, _, _ := strings.Cut(key, "/") | |||
| 506 | + | return root | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | // nextIndex is a position for a job added to the run. | |||
| 510 | + | func (rs *runState) nextIndex() int { | |||
| 511 | + | n := 0 | |||
| 512 | + | for _, j := range rs.jobs { | |||
| 513 | + | n = max(n, j.Index+1) | |||
| 514 | + | } | |||
| 515 | + | return n | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | // planCall expands a job that calls a reusable workflow. rec (at idx) becomes | |||
| 519 | + | // the first call record, one per matrix leg; the jobs of the called workflow are | |||
| 520 | + | // added as waiting records of the scope of each leg. | |||
| 521 | + | func (rs *runState) planCall(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, idx int, env template.Env) error { | |||
| 522 | + | rec := rs.jobs[idx] | |||
| 523 | + | parent := rs.scopeOf(rec) | |||
| 524 | + | var home *domain.CICall | |||
| 525 | + | depth := 1 | |||
| 526 | + | if parent != nil { | |||
| 527 | + | home, depth = parent.Call, parent.Call.Depth+1 | |||
| 528 | + | } | |||
| 529 | + | if depth > maxReusableWorkflowDepth { | |||
| 530 | + | return callFailure{fmt.Sprintf("Nested reusable workflow depth exceeded %d.", maxReusableWorkflowDepth)} | |||
| 531 | + | } | |||
| 532 | + | ref, text := parseCallRef(wj.Uses.Value) | |||
| 533 | + | if text != "" { | |||
| 534 | + | return callFailure{text} | |||
| 535 | + | } | |||
| 536 | + | file, err := rs.c.loadCallee(ctx, rs, home, rec, ref) | |||
| 537 | + | if err != nil { | |||
| 538 | + | return err | |||
| 539 | + | } | |||
| 540 | + | prefix := callerPrefix(wj) | |||
| 541 | + | cw, errs := workflow.Parse(file.path, file.yaml) | |||
| 542 | + | if len(errs) > 0 { | |||
| 543 | + | return callFailure{prefix + " " + errs[0].Error()} | |||
| 544 | + | } | |||
| 545 | + | wc := cw.On.Event("workflow_call") | |||
| 546 | + | if wc == nil { | |||
| 547 | + | return callFailure{prefix + " workflow_call key is not defined in the referenced workflow."} | |||
| 548 | + | } | |||
| 549 | + | spec := readCalleeSpec(file.yaml) | |||
| 550 | + | ||||
| 551 | + | eff, err := rs.callPermissions(wf, wj, rec, parent) | |||
| 552 | + | if err != nil { | |||
| 553 | + | return err | |||
| 554 | + | } | |||
| 555 | + | plan, err := wj.EvaluateStrategy(ctx, env) | |||
| 556 | + | if err != nil { | |||
| 557 | + | return err | |||
| 558 | + | } | |||
| 559 | + | vars, _ := env.Contexts["vars"].(*expr.Dictionary) | |||
| 560 | + | if vars == nil { | |||
| 561 | + | vars = expr.NewDictionary() | |||
| 562 | + | } | |||
| 563 | + | gh := env.Contexts["github"] | |||
| 564 | + | ||||
| 565 | + | // The records of the run are bounded before anything changes: the calls, and | |||
| 566 | + | // the jobs of the called workflow for each (a job that is a matrix adds its | |||
| 567 | + | // legs when it is planned, checked there). | |||
| 568 | + | if err := rs.checkJobBudget(len(plan.Combos) - 1 + len(plan.Combos)*len(cw.Jobs)); err != nil { | |||
| 569 | + | return err | |||
| 570 | + | } | |||
| 571 | + | legs := make([]domain.CIJob, 0, len(plan.Combos)) | |||
| 572 | + | for i := range plan.Combos { | |||
| 573 | + | menv, err := plan.MatrixEnv(env, i) | |||
| 574 | + | if err != nil { | |||
| 575 | + | return err | |||
| 576 | + | } | |||
| 577 | + | var given *expr.Dictionary | |||
| 578 | + | if wj.With != nil { | |||
| 579 | + | v, err := template.Evaluate(ctx, wj.With, menv) | |||
| 580 | + | if err != nil { | |||
| 581 | + | return err | |||
| 582 | + | } | |||
| 583 | + | given, _ = v.(*expr.Dictionary) | |||
| 584 | + | } | |||
| 585 | + | inEnv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars}} | |||
| 586 | + | inputs, err := callInputs(ctx, wc.Inputs, given, inEnv) | |||
| 587 | + | if err != nil { | |||
| 588 | + | if text, ok := isCallFailure(err); ok { | |||
| 589 | + | return callFailure{prefix + " " + text} | |||
| 590 | + | } | |||
| 591 | + | return err | |||
| 592 | + | } | |||
| 593 | + | // The group of the calling job (job-level, evaluated like the group of | |||
| 594 | + | // any job) and the workflow-level group of the called workflow | |||
| 595 | + | // (evaluated with the caller's github context and the call's inputs). | |||
| 596 | + | // The call record is the entry of both. | |||
| 597 | + | var jobConc, wfConc domain.CIConcurrency | |||
| 598 | + | if wj.Concurrency != nil { | |||
| 599 | + | if jobConc, err = evalConcurrency(ctx, wj.Concurrency, menv); err != nil { | |||
| 600 | + | return err | |||
| 601 | + | } | |||
| 602 | + | jobConc.Evaluated = true | |||
| 603 | + | } | |||
| 604 | + | if cw.Concurrency != nil { | |||
| 605 | + | wenv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": inputs, "vars": vars}} | |||
| 606 | + | if wfConc, err = evalConcurrency(ctx, cw.Concurrency, wenv); err != nil { | |||
| 607 | + | return callFailure{prefix + " " + err.Error()} | |||
| 608 | + | } | |||
| 609 | + | wfConc.Evaluated = true | |||
| 610 | + | } | |||
| 611 | + | call := domain.CICall{ | |||
| 612 | + | Uses: wj.Uses.Value, Path: file.path, Repository: file.repository, Ref: file.ref, Sha: file.sha, YAML: string(file.yaml), | |||
| 613 | + | InputsJSON: compactJSON(expr.ToJSON(inputs)), Depth: depth, Expanded: true, Concurrency: wfConc, | |||
| 614 | + | } | |||
| 615 | + | if err := rs.callSecrets(wj, menv, spec, &call); err != nil { | |||
| 616 | + | if text, ok := isCallFailure(err); ok { | |||
| 617 | + | return callFailure{prefix + " " + text} | |||
| 618 | + | } | |||
| 619 | + | return err | |||
| 620 | + | } | |||
| 621 | + | name, err := wj.DisplayName(ctx, menv, plan, i) | |||
| 622 | + | if err != nil { | |||
| 623 | + | return err | |||
| 624 | + | } | |||
| 625 | + | leg := rec | |||
| 626 | + | leg.Combo = i | |||
| 627 | + | leg.ID = domain.NewJobID(rs.run.ID, rec.Index, i) | |||
| 628 | + | if i > 0 { | |||
| 629 | + | leg.Version = 0 | |||
| 630 | + | } | |||
| 631 | + | leg.Name = strings.TrimSpace(name) | |||
| 632 | + | if parent != nil { | |||
| 633 | + | leg.Name = parent.Name + " / " + leg.Name // "<caller> / <nested caller>" | |||
| 634 | + | } | |||
| 635 | + | leg.Call, leg.Planned, leg.Phase = &call, true, domain.CIPhaseWaiting | |||
| 636 | + | leg.Permissions = eff | |||
| 637 | + | leg.Concurrency = jobConc | |||
| 638 | + | leg.MatrixJSON = compactJSON(expr.ToJSON(plan.Combos[i].Values)) | |||
| 639 | + | leg.ContextsJSON, leg.ComboTotal = "{}", len(plan.Combos) | |||
| 640 | + | leg.FailFast, leg.MaxParallel = plan.FailFast, plan.MaxParallel | |||
| 641 | + | leg.Labels, leg.TimeoutMinutes = nil, 0 | |||
| 642 | + | legs = append(legs, leg) | |||
| 643 | + | } | |||
| 644 | + | ||||
| 645 | + | // The new positions follow the highest one in use. | |||
| 646 | + | next := rs.nextIndex() | |||
| 647 | + | if next+len(legs)*len(cw.Jobs) > maxJobIndex+1 { | |||
| 648 | + | return callFailure{"The workflow has more jobs than the server tracks (" + strconv.Itoa(maxJobIndex+1) + ")."} | |||
| 649 | + | } | |||
| 650 | + | rs.jobs[idx] = legs[0] | |||
| 651 | + | rs.touch(&rs.jobs[idx]) | |||
| 652 | + | for _, l := range legs[1:] { | |||
| 653 | + | rs.jobs = append(rs.jobs, l) | |||
| 654 | + | rs.dirty[l.ID] = true | |||
| 655 | + | } | |||
| 656 | + | next = rs.nextIndex() | |||
| 657 | + | now := rs.c.now() | |||
| 658 | + | for _, l := range legs { | |||
| 659 | + | for _, cj := range cw.Jobs { | |||
| 660 | + | needs := make([]string, len(cj.Needs)) | |||
| 661 | + | for i, n := range cj.Needs { | |||
| 662 | + | needs[i] = l.Key + "/" + n | |||
| 663 | + | } | |||
| 664 | + | id := domain.NewJobID(rs.run.ID, next, 0) | |||
| 665 | + | rs.jobs = append(rs.jobs, domain.CIJob{ | |||
| 666 | + | ID: id, RunID: rs.run.ID, Attempt: rs.attempt, RunAttempt: rs.attempt, Repo: rs.run.Repo, Key: l.Key + "/" + cj.ID, | |||
| 667 | + | Name: cj.ID, Phase: domain.CIPhaseWaiting, Needs: needs, Index: next, CreatedAt: now, Scope: l.ID, CallID: cj.ID, | |||
| 668 | + | }) | |||
| 669 | + | rs.dirty[id] = true | |||
| 670 | + | next++ | |||
| 671 | + | } | |||
| 672 | + | } | |||
| 673 | + | rs.sortJobs() | |||
| 674 | + | return nil | |||
| 675 | + | } | |||
| 676 | + | ||||
| 677 | + | // callPermissions is the permissions a call grants to the jobs of its scope: the | |||
| 678 | + | // "permissions:" of the calling job, else of its workflow, else those the | |||
| 679 | + | // containing call allows. A reusable workflow can only keep or lower them: a | |||
| 680 | + | // request above the allowed is the error of PermissionsHelper.cs. | |||
| 681 | + | func (rs *runState) callPermissions(wf *workflow.Workflow, wj *workflow.Job, rec domain.CIJob, parent *domain.CIJob) (map[string]string, error) { | |||
| 682 | + | got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |||
| 683 | + | if parent == nil { | |||
| 684 | + | return got, nil | |||
| 685 | + | } | |||
| 686 | + | allowed := domain.TokenPermissions(parent.Permissions, true) | |||
| 687 | + | if got == nil { | |||
| 688 | + | return allowed, nil | |||
| 689 | + | } | |||
| 690 | + | if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 { | |||
| 691 | + | return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. The nested job '%s' is requesting '%s', but is only allowed '%s'.", | |||
| 692 | + | parent.Call.Uses, wj.ID, strings.Join(asked, ", "), strings.Join(ok, ", "))} | |||
| 693 | + | } | |||
| 694 | + | return got, nil | |||
| 695 | + | } | |||
| 696 | + | ||||
| 697 | + | // scopedPermissions are the permissions of a job of a called workflow: its own | |||
| 698 | + | // "permissions:", else its workflow's, else what the call allows; asking for | |||
| 699 | + | // more than the call allows is an error. | |||
| 700 | + | func (rs *runState) scopedPermissions(wf *workflow.Workflow, wj *workflow.Job, call *domain.CIJob) (map[string]string, error) { | |||
| 701 | + | allowed := domain.TokenPermissions(call.Permissions, true) | |||
| 702 | + | got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |||
| 703 | + | if got == nil { | |||
| 704 | + | return call.Permissions, nil | |||
| 705 | + | } | |||
| 706 | + | if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 { | |||
| 707 | + | what := "The nested job '" + wj.ID + "'" | |||
| 708 | + | if wj.Permissions == nil { | |||
| 709 | + | what = "The workflow" | |||
| 710 | + | } | |||
| 711 | + | return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. %s is requesting '%s', but is only allowed '%s'.", | |||
| 712 | + | call.Call.Uses, what, strings.Join(asked, ", "), strings.Join(ok, ", "))} | |||
| 713 | + | } | |||
| 714 | + | return got, nil | |||
| 715 | + | } | |||
| 716 | + | ||||
| 717 | + | // callSecrets reads the "secrets:" of a call: "inherit", or a mapping. Planning | |||
| 718 | + | // validates names only (the expressions were checked against the contexts the | |||
| 719 | + | // schema allows when the file was parsed) and keeps no expression and no value: | |||
| 720 | + | // the mapping is read from the pinned workflow file, as a template token, when | |||
| 721 | + | // a job is assigned (callMapping, mapSecrets), and evaluated once there. Only | |||
| 722 | + | // the plain contexts the expressions read (needs, matrix, strategy, inputs, | |||
| 723 | + | // vars: none is secret) are kept, in call.SecretCtx, as they are now. | |||
| 724 | + | func (rs *runState) callSecrets(wj *workflow.Job, env template.Env, spec calleeSpec, call *domain.CICall) error { | |||
| 725 | + | if wj.InheritSecrets { | |||
| 726 | + | call.Inherit = true | |||
| 727 | + | return nil | |||
| 728 | + | } | |||
| 729 | + | if wj.Secrets == nil { | |||
| 730 | + | return rs.checkRequiredSecrets(spec, map[string]bool{}) | |||
| 731 | + | } | |||
| 732 | + | call.Secrets = map[string]string{} | |||
| 733 | + | given := map[string]bool{} | |||
| 734 | + | for _, p := range wj.Secrets.Pairs { | |||
| 735 | + | name := strings.ToUpper(p.Key.String()) | |||
| 736 | + | if _, ok := spec.secrets[name]; !ok { | |||
| 737 | + | return callFailure{"Invalid secret, " + p.Key.String() + " is not defined in the referenced workflow."} | |||
| 738 | + | } | |||
| 739 | + | given[name] = true | |||
| 740 | + | call.Secrets[name] = "" // the name only | |||
| 741 | + | } | |||
| 742 | + | plain := map[string]json.RawMessage{} | |||
| 743 | + | for _, k := range []string{"needs", "matrix", "strategy", "inputs", "vars"} { | |||
| 744 | + | if v, ok := env.Contexts[k]; ok && v != nil { | |||
| 745 | + | plain[k] = json.RawMessage(compactJSON(expr.ToJSON(v))) | |||
| 746 | + | } | |||
| 747 | + | } | |||
| 748 | + | b, err := json.Marshal(plain) | |||
| 749 | + | if err != nil { | |||
| 750 | + | return err | |||
| 751 | + | } | |||
| 752 | + | call.SecretCtx = string(b) | |||
| 753 | + | return rs.checkRequiredSecrets(spec, given) | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | // callMapping returns the "secrets:" mapping of a call as the template tokens of | |||
| 757 | + | // the workflow file that holds the calling job: the run's own workflow, or the | |||
| 758 | + | // called workflow of the call that contains it. The tokens come from the file | |||
| 759 | + | // itself and are never rebuilt from text; names are upper-cased. | |||
| 760 | + | func (rs *runState) callMapping(cr *domain.CIJob) (map[string]template.Token, error) { | |||
| 761 | + | path, yamlText, id := rs.run.WorkflowPath, rs.src.WorkflowYAML, cr.Key | |||
| 762 | + | if p := rs.scopeOf(*cr); p != nil && p.Call != nil { | |||
| 763 | + | path, yamlText, id = p.Call.Path, p.Call.YAML, cr.CallID | |||
| 764 | + | } | |||
| 765 | + | wf, err := rs.c.flows.parseCalled(path, yamlText) | |||
| 766 | + | if err != nil { | |||
| 767 | + | return nil, err | |||
| 768 | + | } | |||
| 769 | + | wj := wf.Job(id) | |||
| 770 | + | if wj == nil { | |||
| 771 | + | return nil, fmt.Errorf("the calling job is gone") | |||
| 772 | + | } | |||
| 773 | + | out := map[string]template.Token{} | |||
| 774 | + | if wj.Secrets != nil { | |||
| 775 | + | for _, p := range wj.Secrets.Pairs { | |||
| 776 | + | out[strings.ToUpper(p.Key.String())] = p.Value | |||
| 777 | + | } | |||
| 778 | + | } | |||
| 779 | + | return out, nil | |||
| 780 | + | } | |||
| 781 | + | ||||
| 782 | + | // secretsReadBy returns the secrets a template token refers to: the expressions | |||
| 783 | + | // of the token are parsed, not the text they produce. | |||
| 784 | + | func secretsReadBy(t template.Token) secretUse { | |||
| 785 | + | w := &secretWalk{names: map[string]bool{}} | |||
| 786 | + | template.Walk(t, func(x template.Token) bool { | |||
| 787 | + | if be, ok := x.(*template.BasicExpression); ok { | |||
| 788 | + | w.expression(be.Expression) | |||
| 789 | + | } | |||
| 790 | + | return true | |||
| 791 | + | }) | |||
| 792 | + | use := secretUse{All: w.all || w.tooDeep} | |||
| 793 | + | for n := range w.names { | |||
| 794 | + | use.Names = append(use.Names, n) | |||
| 795 | + | } | |||
| 796 | + | slices.Sort(use.Names) | |||
| 797 | + | return use | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | func (rs *runState) checkRequiredSecrets(spec calleeSpec, given map[string]bool) error { | |||
| 801 | + | for _, name := range sortedKeys(spec.secrets) { | |||
| 802 | + | if spec.secrets[name] && !given[name] { | |||
| 803 | + | return callFailure{"Secret " + name + " is required, but not provided while calling."} | |||
| 804 | + | } | |||
| 805 | + | } | |||
| 806 | + | return nil | |||
| 807 | + | } | |||
| 808 | + | ||||
| 809 | + | // chainOf returns the call records a job runs under, innermost first. | |||
| 810 | + | func (rs *runState) chainOf(j domain.CIJob) []*domain.CIJob { | |||
| 811 | + | var out []*domain.CIJob | |||
| 812 | + | for sc := rs.scopeOf(j); sc != nil && len(out) <= maxReusableWorkflowDepth; sc = rs.scopeOf(*sc) { | |||
| 813 | + | out = append(out, sc) | |||
| 814 | + | } | |||
| 815 | + | return out | |||
| 816 | + | } | |||
| 817 | + | ||||
| 818 | + | // scopedSecretUse is the secrets of the run's own workflow that a job of a | |||
| 819 | + | // called workflow reads, through the "secrets:" of each call that leads to it. | |||
| 820 | + | // inner is the use inside the called workflow of the job. | |||
| 821 | + | func (rs *runState) scopedSecretUse(j domain.CIJob) (top, inner secretUse) { | |||
| 822 | + | chain := rs.chainOf(j) | |||
| 823 | + | inner = secretsUsedBy(chain[0].Call.YAML, j.CallID) | |||
| 824 | + | cur := inner | |||
| 825 | + | for _, c := range chain { | |||
| 826 | + | cur = rs.translateUse(cur, c) | |||
| 827 | + | } | |||
| 828 | + | return cur, inner | |||
| 829 | + | } | |||
| 830 | + | ||||
| 831 | + | // translateUse turns the secrets a called workflow reads into the secrets its | |||
| 832 | + | // caller must hold: with "inherit" the same ones, else the secrets that the | |||
| 833 | + | // expressions of the call's mapping, for the ones it reads, refer to (all of | |||
| 834 | + | // them when an expression is not a plain name). A mapping that cannot be read | |||
| 835 | + | // asks for all of them: the safe side for the job, which then gets none it | |||
| 836 | + | // cannot evaluate. | |||
| 837 | + | func (rs *runState) translateUse(use secretUse, cr *domain.CIJob) secretUse { | |||
| 838 | + | if cr.Call.Inherit { | |||
| 839 | + | return use | |||
| 840 | + | } | |||
| 841 | + | mapping, err := rs.callMapping(cr) | |||
| 842 | + | if err != nil { | |||
| 843 | + | return secretUse{All: true} | |||
| 844 | + | } | |||
| 845 | + | var out secretUse | |||
| 846 | + | add := func(t template.Token) { | |||
| 847 | + | u := secretsReadBy(t) | |||
| 848 | + | out.All = out.All || u.All | |||
| 849 | + | for _, n := range u.Names { | |||
| 850 | + | if !slices.Contains(out.Names, n) { | |||
| 851 | + | out.Names = append(out.Names, n) | |||
| 852 | + | } | |||
| 853 | + | } | |||
| 854 | + | } | |||
| 855 | + | for name, t := range mapping { | |||
| 856 | + | if use.All || slices.Contains(use.Names, name) { | |||
| 857 | + | add(t) | |||
| 858 | + | } | |||
| 859 | + | } | |||
| 860 | + | slices.Sort(out.Names) | |||
| 861 | + | return out | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | // mapSecrets gives a job of a called workflow the secrets it reads, named as the | |||
| 865 | + | // called workflow names them. top holds the secrets of the run's own workflow | |||
| 866 | + | // that it may use (empty for a run that withholds them: the caller decides who | |||
| 867 | + | // gets any). From the outermost call, each mapping value is the template token | |||
| 868 | + | // of the workflow file, evaluated once with the secrets of its calling scope and | |||
| 869 | + | // the plain contexts the call kept; the result is a final string, never parsed | |||
| 870 | + | // again, and is the secrets of the scope below. A secret not passed (not in | |||
| 871 | + | // "secrets:" and no "inherit") is not there. A value that does not evaluate is | |||
| 872 | + | // empty, and nothing of the error is logged: it may hold the secret. | |||
| 873 | + | func (rs *runState) mapSecrets(ctx context.Context, j domain.CIJob, top map[string]string, inner secretUse) map[string]string { | |||
| 874 | + | chain := rs.chainOf(j) | |||
| 875 | + | vals := top | |||
| 876 | + | gh, ghErr := rs.githubValue("", rs.run.Attempt) | |||
| 877 | + | for i := len(chain) - 1; i >= 0; i-- { | |||
| 878 | + | cr := chain[i] | |||
| 879 | + | if cr.Call.Inherit { | |||
| 880 | + | continue | |||
| 881 | + | } | |||
| 882 | + | mapping, merr := rs.callMapping(cr) | |||
| 883 | + | sd := expr.NewDictionary() | |||
| 884 | + | for _, k := range sortedKeys(vals) { | |||
| 885 | + | sd.Add(k, expr.String(vals[k])) | |||
| 886 | + | } | |||
| 887 | + | env := template.Env{Contexts: map[string]expr.Value{"secrets": sd}} | |||
| 888 | + | if ghErr == nil { | |||
| 889 | + | env.Contexts["github"] = gh | |||
| 890 | + | } | |||
| 891 | + | var plain map[string]json.RawMessage | |||
| 892 | + | _ = json.Unmarshal([]byte(cr.Call.SecretCtx), &plain) | |||
| 893 | + | for k, raw := range plain { | |||
| 894 | + | if v, err := expr.FromJSON(raw); err == nil { | |||
| 895 | + | env.Contexts[k] = v | |||
| 896 | + | } | |||
| 897 | + | } | |||
| 898 | + | next := map[string]string{} | |||
| 899 | + | for name := range cr.Call.Secrets { | |||
| 900 | + | t, ok := mapping[name] | |||
| 901 | + | if merr != nil || !ok { | |||
| 902 | + | next[name] = "" | |||
| 903 | + | continue | |||
| 904 | + | } | |||
| 905 | + | v, err := template.EvaluateString(ctx, t, env) | |||
| 906 | + | if err != nil { | |||
| 907 | + | // Never the error: a format() error holds its arguments. | |||
| 908 | + | ctxLog(ctx).Warn().Str("secret", name).Msg("ci: a secret passed to a called workflow could not evaluate: it is empty") | |||
| 909 | + | v = "" | |||
| 910 | + | } | |||
| 911 | + | next[name] = v | |||
| 912 | + | } | |||
| 913 | + | vals = next | |||
| 914 | + | } | |||
| 915 | + | out := map[string]string{} | |||
| 916 | + | for n, v := range vals { | |||
| 917 | + | if inner.All || slices.Contains(inner.Names, n) { | |||
| 918 | + | out[n] = v | |||
| 919 | + | } | |||
| 920 | + | } | |||
| 921 | + | return out | |||
| 922 | + | } | |||
| 923 | + | ||||
| 924 | + | // callHeld reports whether a call holds the concurrency groups it asked for: the | |||
| 925 | + | // group of the calling job and the workflow-level group of the called workflow. | |||
| 926 | + | // The jobs of a call are not planned before. | |||
| 927 | + | func callHeld(call *domain.CIJob) bool { | |||
| 928 | + | if call.Call == nil || !call.Call.Admitted { | |||
| 929 | + | return false | |||
| 930 | + | } | |||
| 931 | + | if call.Concurrency.Group != "" && !call.Concurrency.FromRun && call.Concurrency.State != domain.CIConcurrencyHolding { | |||
| 932 | + | return false | |||
| 933 | + | } | |||
| 934 | + | return call.Call == nil || call.Call.Concurrency.Group == "" || call.Call.Concurrency.State == domain.CIConcurrencyHolding | |||
| 935 | + | } | |||
| 936 | + | ||||
| 937 | + | // admitCalls lets the calls of a matrix start while the max-parallel of the | |||
| 938 | + | // calling job's strategy allows it, in the order of the legs, as promote does | |||
| 939 | + | // for the jobs that run. A call that is admitted asks for its groups and has its | |||
| 940 | + | // jobs planned; one that is not waits, and starts nothing. | |||
| 941 | + | func (rs *runState) admitCalls() bool { | |||
| 942 | + | running := map[string]int{} | |||
| 943 | + | for _, j := range rs.jobs { | |||
| 944 | + | if j.Call != nil && j.Call.Admitted && !j.Ended() { | |||
| 945 | + | running[j.Scope+"\x00"+j.Key]++ | |||
| 946 | + | } | |||
| 947 | + | } | |||
| 948 | + | changed := false | |||
| 949 | + | for i := range rs.jobs { | |||
| 950 | + | j := &rs.jobs[i] | |||
| 951 | + | if j.Call == nil || j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting { | |||
| 952 | + | continue | |||
| 953 | + | } | |||
| 954 | + | k := j.Scope + "\x00" + j.Key | |||
| 955 | + | if j.MaxParallel > 0 && running[k] >= j.MaxParallel { | |||
| 956 | + | continue | |||
| 957 | + | } | |||
| 958 | + | call := *j.Call | |||
| 959 | + | call.Admitted = true | |||
| 960 | + | j.Call, changed = &call, true | |||
| 961 | + | running[k]++ | |||
| 962 | + | rs.touch(j) | |||
| 963 | + | } | |||
| 964 | + | return changed | |||
| 965 | + | } | |||
| 966 | + | ||||
| 967 | + | // checkJobBudget refuses an expansion that would give the run more job records | |||
| 968 | + | // than a commit may have checks (MaxCIChecks: each record has one check, and the | |||
| 969 | + | // merge rule cannot read more). extra is the number of records the expansion | |||
| 970 | + | // adds (the record of the job being expanded becomes the first of its legs). | |||
| 971 | + | // GitHub's text is the runner's (JobCountValidator.cs); its limit applies to the | |||
| 972 | + | // jobs across all referenced files, and the matrix of one job is capped at 256 | |||
| 973 | + | // by ermite-actions (ErrTooManyConfigurations). | |||
| 974 | + | func (rs *runState) checkJobBudget(extra int) error { | |||
| 975 | + | if len(rs.jobs)+extra > MaxCIChecks { | |||
| 976 | + | return callFailure{fmt.Sprintf("Workflows may not contain more than %d jobs across all referenced files", MaxCIChecks)} | |||
| 977 | + | } | |||
| 978 | + | return nil | |||
| 979 | + | } | |||
| 980 | + | ||||
| 981 | + | // holdCalls asks for the turn of the calls that wait for a group: the group of | |||
| 982 | + | // the calling job first, then the one of the called workflow, so that a call | |||
| 983 | + | // waits in one group at a time. The call record is the entry, and holds both | |||
| 984 | + | // until it ends (releaseEnded). | |||
| 985 | + | func (rs *runState) holdCalls(ctx context.Context) (bool, error) { | |||
| 986 | + | changed := false | |||
| 987 | + | for i := range rs.jobs { | |||
| 988 | + | j := &rs.jobs[i] | |||
| 989 | + | if j.Call == nil || !j.Call.Expanded || !j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting { | |||
| 990 | + | continue | |||
| 991 | + | } | |||
| 992 | + | entry := domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID} | |||
| 993 | + | if g := j.Concurrency; g.Group != "" && !g.FromRun && g.State != domain.CIConcurrencyHolding { | |||
| 994 | + | e := entry | |||
| 995 | + | e.Cancel = g.Cancel | |||
| 996 | + | st, err := rs.acquire(ctx, g.Group, e) | |||
| 997 | + | if err != nil { | |||
| 998 | + | return changed, err | |||
| 999 | + | } | |||
| 1000 | + | if st != g.State { | |||
| 1001 | + | j.Concurrency.State, changed = st, true | |||
| 1002 | + | rs.touch(j) | |||
| 1003 | + | } | |||
| 1004 | + | if st != domain.CIConcurrencyHolding { | |||
| 1005 | + | continue | |||
| 1006 | + | } | |||
| 1007 | + | } | |||
| 1008 | + | if g := j.Call.Concurrency; g.Group != "" && g.State != domain.CIConcurrencyHolding { | |||
| 1009 | + | e := entry | |||
| 1010 | + | e.Cancel = g.Cancel | |||
| 1011 | + | st, err := rs.acquire(ctx, g.Group, e) | |||
| 1012 | + | if err != nil { | |||
| 1013 | + | return changed, err | |||
| 1014 | + | } | |||
| 1015 | + | if st != g.State { | |||
| 1016 | + | call := *j.Call | |||
| 1017 | + | call.Concurrency.State = st | |||
| 1018 | + | j.Call, changed = &call, true | |||
| 1019 | + | rs.touch(j) | |||
| 1020 | + | } | |||
| 1021 | + | } | |||
| 1022 | + | } | |||
| 1023 | + | return changed, nil | |||
| 1024 | + | } | |||
| 1025 | + | ||||
| 1026 | + | // advanceCalls plans the jobs of called workflows whose needs ended and ends | |||
| 1027 | + | // the call records whose jobs have. It returns at the first change, since a | |||
| 1028 | + | // change reorders the records. | |||
| 1029 | + | func (rs *runState) advanceCalls(ctx context.Context) (bool, error) { | |||
| 1030 | + | if rs.admitCalls() { | |||
| 1031 | + | return true, nil | |||
| 1032 | + | } | |||
| 1033 | + | if changed, err := rs.holdCalls(ctx); err != nil || changed { | |||
| 1034 | + | return changed, err | |||
| 1035 | + | } | |||
| 1036 | + | for i := range rs.jobs { | |||
| 1037 | + | rec := rs.jobs[i] | |||
| 1038 | + | if rec.Scope == "" || rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting { | |||
| 1039 | + | continue | |||
| 1040 | + | } | |||
| 1041 | + | call := rs.scopeOf(rec) | |||
| 1042 | + | if call == nil { | |||
| 1043 | + | rs.end(&rs.jobs[i], domain.CIFailure, "the call of this job is gone") | |||
| 1044 | + | return true, nil | |||
| 1045 | + | } | |||
| 1046 | + | if call.Ended() { | |||
| 1047 | + | rs.end(&rs.jobs[i], domain.CICancelled, "") | |||
| 1048 | + | return true, nil | |||
| 1049 | + | } | |||
| 1050 | + | if call.Call == nil || !call.Call.Admitted { | |||
| 1051 | + | continue // the max-parallel of the calling job holds the call back | |||
| 1052 | + | } | |||
| 1053 | + | if !callHeld(call) { | |||
| 1054 | + | // Waits for the group of its call: shown as pending, as the jobs | |||
| 1055 | + | // of a run behind a workflow-level group. | |||
| 1056 | + | if rs.run.Concurrency.Group == "" && rec.Concurrency.State != domain.CIConcurrencyPending { | |||
| 1057 | + | g := call.Concurrency | |||
| 1058 | + | if g.Group == "" || g.State == domain.CIConcurrencyHolding { | |||
| 1059 | + | g = call.Call.Concurrency | |||
| 1060 | + | } | |||
| 1061 | + | rs.jobs[i].Concurrency = domain.CIConcurrency{Group: g.Group, State: domain.CIConcurrencyPending, FromRun: true, Evaluated: true} | |||
| 1062 | + | rs.touch(&rs.jobs[i]) | |||
| 1063 | + | return true, nil | |||
| 1064 | + | } | |||
| 1065 | + | continue | |||
| 1066 | + | } | |||
| 1067 | + | if rec.Concurrency.FromRun && rs.run.Concurrency.Group == "" { | |||
| 1068 | + | rs.jobs[i].Concurrency = domain.CIConcurrency{} | |||
| 1069 | + | rs.touch(&rs.jobs[i]) | |||
| 1070 | + | } | |||
| 1071 | + | cw, err := rs.calleeOf(call) | |||
| 1072 | + | if err != nil { | |||
| 1073 | + | rs.end(&rs.jobs[i], domain.CIFailure, err.Error()) | |||
| 1074 | + | return true, nil | |||
| 1075 | + | } | |||
| 1076 | + | cj := cw.Job(rec.CallID) | |||
| 1077 | + | if cj == nil { | |||
| 1078 | + | rs.end(&rs.jobs[i], domain.CIFailure, "the called workflow has no job '"+rec.CallID+"'") | |||
| 1079 | + | return true, nil | |||
| 1080 | + | } | |||
| 1081 | + | if len(rs.instancesIn(rec.Scope, rec.CallID)) != 1 { | |||
| 1082 | + | continue | |||
| 1083 | + | } | |||
| 1084 | + | needs, st, ready := rs.needsOf(cw, cj, rec.Scope) | |||
| 1085 | + | if !ready { | |||
| 1086 | + | continue | |||
| 1087 | + | } | |||
| 1088 | + | rs.planJob(ctx, cw, cj, i, needs, st) | |||
| 1089 | + | return true, nil | |||
| 1090 | + | } | |||
| 1091 | + | for i := range rs.jobs { | |||
| 1092 | + | call := &rs.jobs[i] | |||
| 1093 | + | if call.Call == nil || !call.Call.Expanded { | |||
| 1094 | + | continue | |||
| 1095 | + | } | |||
| 1096 | + | var scoped []int | |||
| 1097 | + | allEnded := true | |||
| 1098 | + | for k, j := range rs.jobs { | |||
| 1099 | + | if j.Scope == call.ID { | |||
| 1100 | + | scoped = append(scoped, k) | |||
| 1101 | + | allEnded = allEnded && j.Ended() | |||
| 1102 | + | } | |||
| 1103 | + | } | |||
| 1104 | + | if call.Ended() { | |||
| 1105 | + | changed := false | |||
| 1106 | + | for _, k := range scoped { | |||
| 1107 | + | s := &rs.jobs[k] | |||
| 1108 | + | switch { | |||
| 1109 | + | case s.Ended(): | |||
| 1110 | + | case s.Active(): | |||
| 1111 | + | rs.requestCancel(s, domain.CICancelUser, endedCallMessage(*call)) | |||
| 1112 | + | default: | |||
| 1113 | + | rs.end(s, domain.CICancelled, "") | |||
| 1114 | + | changed = true | |||
| 1115 | + | } | |||
| 1116 | + | } | |||
| 1117 | + | if changed { | |||
| 1118 | + | return true, nil | |||
| 1119 | + | } | |||
| 1120 | + | continue | |||
| 1121 | + | } | |||
| 1122 | + | if allEnded { | |||
| 1123 | + | rs.finishCall(ctx, i, scoped) | |||
| 1124 | + | return true, nil | |||
| 1125 | + | } | |||
| 1126 | + | } | |||
| 1127 | + | return false, nil | |||
| 1128 | + | } | |||
| 1129 | + | ||||
| 1130 | + | // finishCall ends a call record with the conclusion of its jobs and the outputs | |||
| 1131 | + | // of the called workflow. | |||
| 1132 | + | func (rs *runState) finishCall(ctx context.Context, idx int, scoped []int) { | |||
| 1133 | + | call := &rs.jobs[idx] | |||
| 1134 | + | var top []domain.CIJob | |||
| 1135 | + | for _, k := range scoped { | |||
| 1136 | + | top = append(top, rs.jobs[k]) | |||
| 1137 | + | } | |||
| 1138 | + | conc, msg := domain.CISuccess, "" | |||
| 1139 | + | switch result(top) { | |||
| 1140 | + | case "failure": | |||
| 1141 | + | conc = domain.CIFailure | |||
| 1142 | + | msg = "A job of the called workflow failed" | |||
| 1143 | + | for _, j := range top { | |||
| 1144 | + | if j.Conclusion.IsFailure() && !j.ContinueOnError { | |||
| 1145 | + | msg = "The job '" + j.Name + "' of the called workflow failed" | |||
| 1146 | + | break | |||
| 1147 | + | } | |||
| 1148 | + | } | |||
| 1149 | + | case "cancelled": | |||
| 1150 | + | conc = domain.CICancelled | |||
| 1151 | + | case "skipped": | |||
| 1152 | + | conc = domain.CISkipped | |||
| 1153 | + | } | |||
| 1154 | + | jobs := expr.NewDictionary() | |||
| 1155 | + | for _, id := range distinctCallIDs(top) { | |||
| 1156 | + | var insts []domain.CIJob | |||
| 1157 | + | for _, j := range top { | |||
| 1158 | + | if j.CallID == id { | |||
| 1159 | + | insts = append(insts, j) | |||
| 1160 | + | } | |||
| 1161 | + | } | |||
| 1162 | + | outs := expr.NewDictionary() | |||
| 1163 | + | for _, in := range insts { | |||
| 1164 | + | for _, k := range sortedKeys(in.Outputs) { | |||
| 1165 | + | if v := in.Outputs[k]; v != "" || !hasKey(outs, k) { | |||
| 1166 | + | setKey(outs, k, expr.String(v)) | |||
| 1167 | + | } | |||
| 1168 | + | } | |||
| 1169 | + | } | |||
| 1170 | + | d := expr.NewDictionary() | |||
| 1171 | + | d.Add("result", expr.String(result(insts))) | |||
| 1172 | + | d.Add("outputs", outs) | |||
| 1173 | + | jobs.Add(id, d) | |||
| 1174 | + | } | |||
| 1175 | + | outputs := map[string]string{} | |||
| 1176 | + | spec := readCalleeSpec([]byte(call.Call.YAML)) | |||
| 1177 | + | if len(spec.outputs) > 0 { | |||
| 1178 | + | gh, err := rs.githubValue(call.Key, rs.run.Attempt) | |||
| 1179 | + | vars, verr := rs.c.varsContext(ctx, rs.run.Repo) | |||
| 1180 | + | if err == nil && verr == nil { | |||
| 1181 | + | env := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars, "jobs": jobs}} | |||
| 1182 | + | // The inputs of the call itself. | |||
| 1183 | + | if v, err := expr.FromJSON([]byte(call.Call.InputsJSON)); err == nil { | |||
| 1184 | + | env.Contexts["inputs"] = v | |||
| 1185 | + | } | |||
| 1186 | + | for _, name := range sortedKeys(spec.outputs) { | |||
| 1187 | + | v, err := evalInterpolated(ctx, spec.outputs[name], env) | |||
| 1188 | + | if err != nil { | |||
| 1189 | + | ctxLog(ctx).Warn().Str("output", name).Msg("ci: an output of a called workflow could not evaluate") | |||
| 1190 | + | continue | |||
| 1191 | + | } | |||
| 1192 | + | outputs[name] = expr.CoerceString(v) | |||
| 1193 | + | } | |||
| 1194 | + | } | |||
| 1195 | + | } | |||
| 1196 | + | call.Outputs = outputs | |||
| 1197 | + | rs.end(call, conc, msg) | |||
| 1198 | + | } | |||
| 1199 | + | ||||
| 1200 | + | func distinctCallIDs(js []domain.CIJob) []string { | |||
| 1201 | + | var out []string | |||
| 1202 | + | for _, j := range js { | |||
| 1203 | + | if !slices.Contains(out, j.CallID) { | |||
| 1204 | + | out = append(out, j.CallID) | |||
| 1205 | + | } | |||
| 1206 | + | } | |||
| 1207 | + | return out | |||
| 1208 | + | } | |||
| 1209 | + | ||||
| 1210 | + | // parseCalled parses a called workflow, once per distinct file. | |||
| 1211 | + | func (w *wfCache) parseCalled(path, yamlText string) (*workflow.Workflow, error) { | |||
| 1212 | + | sum := sha256.Sum256([]byte(path + "\x00" + yamlText)) | |||
| 1213 | + | key := hex.EncodeToString(sum[:]) | |||
| 1214 | + | w.mu.Lock() | |||
| 1215 | + | if wf, ok := w.called[key]; ok { | |||
| 1216 | + | w.mu.Unlock() | |||
| 1217 | + | return wf, nil | |||
| 1218 | + | } | |||
| 1219 | + | w.mu.Unlock() | |||
| 1220 | + | wf, errs := workflow.Parse(path, []byte(yamlText)) | |||
| 1221 | + | if len(errs) > 0 { | |||
| 1222 | + | return nil, fmt.Errorf("the called workflow no longer parses: %s", errs[0].Error()) | |||
| 1223 | + | } | |||
| 1224 | + | w.mu.Lock() | |||
| 1225 | + | defer w.mu.Unlock() | |||
| 1226 | + | if len(w.called) >= w.max { | |||
| 1227 | + | clear(w.called) | |||
| 1228 | + | } | |||
| 1229 | + | w.called[key] = wf | |||
| 1230 | + | return wf, nil | |||
| 1231 | + | } | |||
| 1232 | + | ||||
| 1233 | + | // jobKeysOf are the keys of the "job" context the server knows. A job of a | |||
| 1234 | + | // called workflow names the called workflow in them (workflow_ref, | |||
| 1235 | + | // workflow_sha, workflow_repository, workflow_file_path); github.workflow_ref | |||
| 1236 | + | // and github.workflow_sha stay the caller's (the github context is the | |||
| 1237 | + | // caller's). | |||
| 1238 | + | func (rs *runState) jobKeysOf(ctx context.Context, base domain.CIJob, combo int) ordered { | |||
| 1239 | + | keys := rs.c.jobKeys(ctx, rs.run, base.Index, combo) | |||
| 1240 | + | call := rs.scopeOf(base) | |||
| 1241 | + | if call == nil || call.Call == nil { | |||
| 1242 | + | return keys | |||
| 1243 | + | } | |||
| 1244 | + | c := call.Call | |||
| 1245 | + | full := c.Repository | |||
| 1246 | + | if !strings.Contains(full, "/") { | |||
| 1247 | + | full = rs.c.cfg.Owner + "/" + full | |||
| 1248 | + | } | |||
| 1249 | + | ref := c.Ref | |||
| 1250 | + | if ref == "" { | |||
| 1251 | + | ref = rs.run.Ref | |||
| 1252 | + | } | |||
| 1253 | + | return ordered{ | |||
| 1254 | + | keys[0], {"workflow_ref", full + "/" + c.Path + "@" + ref}, {"workflow_sha", c.Sha}, | |||
| 1255 | + | {"workflow_repository", full}, {"workflow_file_path", c.Path}, | |||
| 1256 | + | } | |||
| 1257 | + | } | |||
| 1258 | + | ||||
| 1259 | + | // endedCallMessage is what the jobs of a call that ended are told: the reason | |||
| 1260 | + | // the call was cancelled for (a newer call of its concurrency group), else that | |||
| 1261 | + | // the calling job ended. | |||
| 1262 | + | func endedCallMessage(call domain.CIJob) string { | |||
| 1263 | + | if call.Concurrency.Preempted || call.Error != "" { | |||
| 1264 | + | return call.Error | |||
| 1265 | + | } | |||
| 1266 | + | return "Canceled because the calling job ended" | |||
| 1267 | + | } | |||
| 1268 | + | ||||
| 1269 | + | // callResolutions copies the resolutions of the reusable workflows that the | |||
| 1270 | + | // attempt of rs called to the attempt next. | |||
| 1271 | + | func (c *CI) callResolutions(ctx context.Context, rs *runState, next int32) ([]domain.CIActionResolution, error) { | |||
| 1272 | + | var out []domain.CIActionResolution | |||
| 1273 | + | seen := map[string]bool{} | |||
| 1274 | + | for _, j := range rs.jobs { | |||
| 1275 | + | if j.Call == nil { | |||
| 1276 | + | continue | |||
| 1277 | + | } | |||
| 1278 | + | ref, text := parseCallRef(j.Call.Uses) | |||
| 1279 | + | if text != "" || ref.local { | |||
| 1280 | + | continue | |||
| 1281 | + | } | |||
| 1282 | + | key := actionRef{owner: ref.owner, repo: ref.repo, ref: ref.ref}.key() | |||
| 1283 | + | if seen[key] { | |||
| 1284 | + | continue | |||
| 1285 | + | } | |||
| 1286 | + | seen[key] = true | |||
| 1287 | + | res, err := c.cfg.Store.GetActionResolution(ctx, rs.run.ID, rs.attempt, key) | |||
| 1288 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 1289 | + | continue | |||
| 1290 | + | } | |||
| 1291 | + | if err != nil { | |||
| 1292 | + | return nil, err | |||
| 1293 | + | } | |||
| 1294 | + | res.RunAttempt = next | |||
| 1295 | + | out = append(out, res) | |||
| 1296 | + | } | |||
| 1297 | + | return out, nil | |||
| 1298 | + | } | |||
M internal/usecase/ci_runner.go +7 -1
| @@ -242,9 +242,15 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, sett | |||
| 242 | 242 | if settings.Vars != nil { | |
| 243 | 243 | j.ContextsJSON = withVars(j.ContextsJSON, settings.Vars) | |
| 244 | 244 | } | |
| 245 | + | path, yaml := rs.run.WorkflowPath, rs.src.WorkflowYAML | |
| 246 | + | if call := rs.scopeOf(j); call != nil && call.Call != nil { | |
| 247 | + | // A job of a called workflow runs from the file of the call, under its | |
| 248 | + | // id in that file. | |
| 249 | + | path, yaml, j.Key = call.Call.Path, call.Call.YAML, j.CallID | |
| 250 | + | } | |
| 245 | 251 | return &ports.RunnerJob{ | |
| 246 | 252 | Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref, | |
| 247 | - | WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML, | |
| 253 | + | WorkflowPath: path, WorkflowYAML: yaml, | |
| 248 | 254 | Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted, | |
| 249 | 255 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | |
| 250 | 256 | } | |
| @@ -242,9 +242,15 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, sett | |||||
| 242 | if settings.Vars != nil { | 242 | if settings.Vars != nil { | ||
| 243 | j.ContextsJSON = withVars(j.ContextsJSON, settings.Vars) | 243 | j.ContextsJSON = withVars(j.ContextsJSON, settings.Vars) | ||
| 244 | } | 244 | } | ||
| 245 | + | path, yaml := rs.run.WorkflowPath, rs.src.WorkflowYAML | |||
| 246 | + | if call := rs.scopeOf(j); call != nil && call.Call != nil { | |||
| 247 | + | // A job of a called workflow runs from the file of the call, under its | |||
| 248 | + | // id in that file. | |||
| 249 | + | path, yaml, j.Key = call.Call.Path, call.Call.YAML, j.CallID | |||
| 250 | + | } | |||
| 245 | return &ports.RunnerJob{ | 251 | return &ports.RunnerJob{ | ||
| 246 | Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref, | 252 | Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref, | ||
| 247 | - | WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML, | 253 | + | WorkflowPath: path, WorkflowYAML: yaml, |
| 248 | Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted, | 254 | Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted, | ||
| 249 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | 255 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | ||
| 250 | } | 256 | } | ||
M internal/usecase/ci_runs.go +17 -6
| @@ -378,15 +378,15 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||
| 378 | 378 | w := ports.CIWrite{} | |
| 379 | 379 | var fresh []domain.CIJob | |
| 380 | 380 | for _, j := range rs.jobs { | |
| 381 | - | if !redo[j.Key] { | |
| 381 | + | if !redo[rootKey(j.Key)] { | |
| 382 | 382 | kept := j // kept with its own attempt, steps and log | |
| 383 | 383 | kept.RunAttempt, kept.Version = next, 0 | |
| 384 | 384 | w.Jobs = append(w.Jobs, kept) | |
| 385 | 385 | fresh = append(fresh, kept) | |
| 386 | 386 | continue | |
| 387 | 387 | } | |
| 388 | - | if j.Combo > 0 { | |
| 389 | - | continue // the matrix is expanded again when the job is planned | |
| 388 | + | if j.Combo > 0 || j.Scope != "" { | |
| 389 | + | continue // the matrix, and the jobs of a call, are expanded again when the job is planned | |
| 390 | 390 | } | |
| 391 | 391 | n := domain.CIJob{ | |
| 392 | 392 | ID: domain.NewJobID(runID, j.Index, 0), RunID: runID, Attempt: next, RunAttempt: next, Repo: rs.run.Repo, | |
| @@ -395,6 +395,17 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||
| 395 | 395 | w.Jobs = append(w.Jobs, n) | |
| 396 | 396 | fresh = append(fresh, n) | |
| 397 | 397 | } | |
| 398 | + | if failedOnly { | |
| 399 | + | // The callee of a reusable workflow stays at the commit the run first | |
| 400 | + | // resolved, as the jobs that are kept ran from it; a re-run of | |
| 401 | + | // everything resolves afresh (as for the actions of CI-2, which are | |
| 402 | + | // resolved again in every attempt). | |
| 403 | + | res, err := c.callResolutions(ctx, rs, next) | |
| 404 | + | if err != nil { | |
| 405 | + | return err | |
| 406 | + | } | |
| 407 | + | w.Resolutions = append(w.Resolutions, res...) | |
| 408 | + | } | |
| 398 | 409 | run := rs.run | |
| 399 | 410 | run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion | |
| 400 | 411 | run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now | |
| @@ -443,14 +454,14 @@ func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) { | |||
| 443 | 454 | redo := map[string]bool{} | |
| 444 | 455 | if !failedOnly { | |
| 445 | 456 | for _, j := range rs.jobs { | |
| 446 | - | redo[j.Key] = true | |
| 457 | + | redo[rootKey(j.Key)] = true | |
| 447 | 458 | } | |
| 448 | 459 | return redo, nil | |
| 449 | 460 | } | |
| 450 | 461 | for _, j := range rs.jobs { | |
| 451 | 462 | switch j.Conclusion { | |
| 452 | 463 | case domain.CIFailure, domain.CICancelled, domain.CITimedOut, domain.CIActionRequired: | |
| 453 | - | redo[j.Key] = true | |
| 464 | + | redo[rootKey(j.Key)] = true | |
| 454 | 465 | } | |
| 455 | 466 | } | |
| 456 | 467 | if len(redo) == 0 { | |
| @@ -459,7 +470,7 @@ func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) { | |||
| 459 | 470 | for changed := true; changed; { | |
| 460 | 471 | changed = false | |
| 461 | 472 | for _, j := range rs.jobs { | |
| 462 | - | if redo[j.Key] { | |
| 473 | + | if redo[j.Key] || j.Scope != "" { | |
| 463 | 474 | continue | |
| 464 | 475 | } | |
| 465 | 476 | if slices.ContainsFunc(j.Needs, func(n string) bool { return redo[n] }) { | |
| @@ -378,15 +378,15 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||||
| 378 | w := ports.CIWrite{} | 378 | w := ports.CIWrite{} | ||
| 379 | var fresh []domain.CIJob | 379 | var fresh []domain.CIJob | ||
| 380 | for _, j := range rs.jobs { | 380 | for _, j := range rs.jobs { | ||
| 381 | - | if !redo[j.Key] { | 381 | + | if !redo[rootKey(j.Key)] { |
| 382 | kept := j // kept with its own attempt, steps and log | 382 | kept := j // kept with its own attempt, steps and log | ||
| 383 | kept.RunAttempt, kept.Version = next, 0 | 383 | kept.RunAttempt, kept.Version = next, 0 | ||
| 384 | w.Jobs = append(w.Jobs, kept) | 384 | w.Jobs = append(w.Jobs, kept) | ||
| 385 | fresh = append(fresh, kept) | 385 | fresh = append(fresh, kept) | ||
| 386 | continue | 386 | continue | ||
| 387 | } | 387 | } | ||
| 388 | - | if j.Combo > 0 { | 388 | + | if j.Combo > 0 || j.Scope != "" { |
| 389 | - | continue // the matrix is expanded again when the job is planned | 389 | + | continue // the matrix, and the jobs of a call, are expanded again when the job is planned |
| 390 | } | 390 | } | ||
| 391 | n := domain.CIJob{ | 391 | n := domain.CIJob{ | ||
| 392 | ID: domain.NewJobID(runID, j.Index, 0), RunID: runID, Attempt: next, RunAttempt: next, Repo: rs.run.Repo, | 392 | ID: domain.NewJobID(runID, j.Index, 0), RunID: runID, Attempt: next, RunAttempt: next, Repo: rs.run.Repo, | ||
| @@ -395,6 +395,17 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||||
| 395 | w.Jobs = append(w.Jobs, n) | 395 | w.Jobs = append(w.Jobs, n) | ||
| 396 | fresh = append(fresh, n) | 396 | fresh = append(fresh, n) | ||
| 397 | } | 397 | } | ||
| 398 | + | if failedOnly { | |||
| 399 | + | // The callee of a reusable workflow stays at the commit the run first | |||
| 400 | + | // resolved, as the jobs that are kept ran from it; a re-run of | |||
| 401 | + | // everything resolves afresh (as for the actions of CI-2, which are | |||
| 402 | + | // resolved again in every attempt). | |||
| 403 | + | res, err := c.callResolutions(ctx, rs, next) | |||
| 404 | + | if err != nil { | |||
| 405 | + | return err | |||
| 406 | + | } | |||
| 407 | + | w.Resolutions = append(w.Resolutions, res...) | |||
| 408 | + | } | |||
| 398 | run := rs.run | 409 | run := rs.run | ||
| 399 | run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion | 410 | run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion | ||
| 400 | run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now | 411 | run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now | ||
| @@ -443,14 +454,14 @@ func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) { | |||||
| 443 | redo := map[string]bool{} | 454 | redo := map[string]bool{} | ||
| 444 | if !failedOnly { | 455 | if !failedOnly { | ||
| 445 | for _, j := range rs.jobs { | 456 | for _, j := range rs.jobs { | ||
| 446 | - | redo[j.Key] = true | 457 | + | redo[rootKey(j.Key)] = true |
| 447 | } | 458 | } | ||
| 448 | return redo, nil | 459 | return redo, nil | ||
| 449 | } | 460 | } | ||
| 450 | for _, j := range rs.jobs { | 461 | for _, j := range rs.jobs { | ||
| 451 | switch j.Conclusion { | 462 | switch j.Conclusion { | ||
| 452 | case domain.CIFailure, domain.CICancelled, domain.CITimedOut, domain.CIActionRequired: | 463 | case domain.CIFailure, domain.CICancelled, domain.CITimedOut, domain.CIActionRequired: | ||
| 453 | - | redo[j.Key] = true | 464 | + | redo[rootKey(j.Key)] = true |
| 454 | } | 465 | } | ||
| 455 | } | 466 | } | ||
| 456 | if len(redo) == 0 { | 467 | if len(redo) == 0 { | ||
| @@ -459,7 +470,7 @@ func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) { | |||||
| 459 | for changed := true; changed; { | 470 | for changed := true; changed; { | ||
| 460 | changed = false | 471 | changed = false | ||
| 461 | for _, j := range rs.jobs { | 472 | for _, j := range rs.jobs { | ||
| 462 | - | if redo[j.Key] { | 473 | + | if redo[j.Key] || j.Scope != "" { |
| 463 | continue | 474 | continue | ||
| 464 | } | 475 | } | ||
| 465 | if slices.ContainsFunc(j.Needs, func(n string) bool { return redo[n] }) { | 476 | if slices.ContainsFunc(j.Needs, func(n string) bool { return redo[n] }) { | ||
M internal/usecase/ci_secrets.go +15 -1
| @@ -294,14 +294,28 @@ func (c *CI) settingsOf(ctx context.Context, rs *runState, j domain.CIJob) (port | |||
| 294 | 294 | // that is not trusted does not add environments to the repository. | |
| 295 | 295 | CreateEnvironment: j.Environment != "" && rs.run.Trusted, | |
| 296 | 296 | } | |
| 297 | + | var inner secretUse | |
| 298 | + | scoped := rs.scopeOf(j) != nil | |
| 297 | 299 | if rs.run.Trusted { | |
| 298 | - | use := c.flows.secretUse(rs.run.ID, rs.src.WorkflowYAML, j.Key) | |
| 300 | + | var use secretUse | |
| 301 | + | if scoped { | |
| 302 | + | // A job of a called workflow gets the secrets its caller passes | |
| 303 | + | // it, under the names the called workflow gives them. | |
| 304 | + | use, inner = rs.scopedSecretUse(j) | |
| 305 | + | } else { | |
| 306 | + | use = c.flows.secretUse(rs.run.ID, rs.src.WorkflowYAML, j.Key) | |
| 307 | + | } | |
| 299 | 308 | req.WantSecrets, req.AllSecrets, req.SecretNames = !use.none(), use.All, use.Names | |
| 300 | 309 | } | |
| 301 | 310 | got, err := c.cfg.Settings.Resolve(ctx, req) | |
| 302 | 311 | if err != nil { | |
| 303 | 312 | return ports.JobSettings{}, &settingsError{err} | |
| 304 | 313 | } | |
| 314 | + | if scoped && rs.run.Trusted { | |
| 315 | + | // Also when no stored secret resolved: a mapping can give a literal | |
| 316 | + | // value. A run that is not trusted gets nothing. | |
| 317 | + | got.Secrets = rs.mapSecrets(ctx, j, got.Secrets, inner) | |
| 318 | + | } | |
| 305 | 319 | return got, nil | |
| 306 | 320 | } | |
| 307 | 321 | ||
| @@ -294,14 +294,28 @@ func (c *CI) settingsOf(ctx context.Context, rs *runState, j domain.CIJob) (port | |||||
| 294 | // that is not trusted does not add environments to the repository. | 294 | // that is not trusted does not add environments to the repository. | ||
| 295 | CreateEnvironment: j.Environment != "" && rs.run.Trusted, | 295 | CreateEnvironment: j.Environment != "" && rs.run.Trusted, | ||
| 296 | } | 296 | } | ||
| 297 | + | var inner secretUse | |||
| 298 | + | scoped := rs.scopeOf(j) != nil | |||
| 297 | if rs.run.Trusted { | 299 | if rs.run.Trusted { | ||
| 298 | - | use := c.flows.secretUse(rs.run.ID, rs.src.WorkflowYAML, j.Key) | 300 | + | var use secretUse |
| 301 | + | if scoped { | |||
| 302 | + | // A job of a called workflow gets the secrets its caller passes | |||
| 303 | + | // it, under the names the called workflow gives them. | |||
| 304 | + | use, inner = rs.scopedSecretUse(j) | |||
| 305 | + | } else { | |||
| 306 | + | use = c.flows.secretUse(rs.run.ID, rs.src.WorkflowYAML, j.Key) | |||
| 307 | + | } | |||
| 299 | req.WantSecrets, req.AllSecrets, req.SecretNames = !use.none(), use.All, use.Names | 308 | req.WantSecrets, req.AllSecrets, req.SecretNames = !use.none(), use.All, use.Names | ||
| 300 | } | 309 | } | ||
| 301 | got, err := c.cfg.Settings.Resolve(ctx, req) | 310 | got, err := c.cfg.Settings.Resolve(ctx, req) | ||
| 302 | if err != nil { | 311 | if err != nil { | ||
| 303 | return ports.JobSettings{}, &settingsError{err} | 312 | return ports.JobSettings{}, &settingsError{err} | ||
| 304 | } | 313 | } | ||
| 314 | + | if scoped && rs.run.Trusted { | |||
| 315 | + | // Also when no stored secret resolved: a mapping can give a literal | |||
| 316 | + | // value. A run that is not trusted gets nothing. | |||
| 317 | + | got.Secrets = rs.mapSecrets(ctx, j, got.Secrets, inner) | |||
| 318 | + | } | |||
| 305 | return got, nil | 319 | return got, nil | ||
| 306 | } | 320 | } | ||
| 307 | 321 | ||||
M proto/ermite/storage/ci.proto +50 -0
| @@ -213,6 +213,56 @@ message CIJob { | |||
| 213 | 213 | // The job's concurrency group (its own, else the one of its run) and where | |
| 214 | 214 | // it is in it. | |
| 215 | 215 | CIConcurrency concurrency = 43; | |
| 216 | + | // Reusable workflows (D7). scope is the id of the record of the job that | |
| 217 | + | // called the workflow this job belongs to, empty for a job of the run's own | |
| 218 | + | // workflow; call_id is the id of the job in that called workflow (key is | |
| 219 | + | // "<caller key>/<call id>"). call is set on the record that stands for a job | |
| 220 | + | // calling a reusable workflow: it never runs, it ends when the jobs of its | |
| 221 | + | // scope have. | |
| 222 | + | string scope = 44; | |
| 223 | + | string call_id = 45; | |
| 224 | + | CICall call = 46; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // CICall is the call a reusable-workflow job makes, set when the calling job is | |
| 228 | + | // planned. The called workflow's file is kept here, so that the jobs of the call | |
| 229 | + | // are planned and run from the file of the commit that was resolved. | |
| 230 | + | message CICall { | |
| 231 | + | string uses = 1; | |
| 232 | + | // path of the file in its repository, repository as "owner/repo" (github.com) | |
| 233 | + | // or the name of the repository on this server, ref as the calling workflow | |
| 234 | + | // wrote it ("" for a local call), sha the commit it resolved to. | |
| 235 | + | string path = 2; | |
| 236 | + | string repository = 3; | |
| 237 | + | string ref = 4; | |
| 238 | + | string sha = 5; | |
| 239 | + | string yaml = 6; | |
| 240 | + | // inputs_json is the inputs context of the called workflow: the values given | |
| 241 | + | // and the defaults, typed. | |
| 242 | + | string inputs_json = 7; | |
| 243 | + | // secrets holds the names of the secrets of the called workflow that the | |
| 244 | + | // calling workflow's "secrets:" gives (upper case), with an empty value: the | |
| 245 | + | // expressions are not stored. They are read as template tokens from the pinned | |
| 246 | + | // workflow file of the caller when a job is assigned, and evaluated once there. | |
| 247 | + | map<string, string> secrets = 8; | |
| 248 | + | bool inherit = 9; | |
| 249 | + | int32 depth = 10; | |
| 250 | + | // expanded: the jobs of the called workflow exist. | |
| 251 | + | bool expanded = 11; | |
| 252 | + | // concurrency is the workflow-level group of the called workflow, evaluated | |
| 253 | + | // with the caller's github context and the call's inputs when the call is | |
| 254 | + | // planned. The call record is its entry, like the group of the calling job | |
| 255 | + | // (CIJob.concurrency): the jobs of the call run once the call holds both. | |
| 256 | + | CIConcurrency concurrency = 12; | |
| 257 | + | // admitted: the call may start (the max-parallel of the calling job's | |
| 258 | + | // strategy allows it); it asks for its groups and plans its jobs only then. | |
| 259 | + | bool admitted = 13; | |
| 260 | + | // secret_ctx is the JSON of the contexts the "secrets:" expressions read apart | |
| 261 | + | // from the secrets, as they were when the call was planned: needs, matrix, | |
| 262 | + | // strategy, inputs and vars of the calling scope (none is secret). Plain data, | |
| 263 | + | // never interpreted as a reference; the vars are the caller's, not those of | |
| 264 | + | // the environment of the job that is assigned. | |
| 265 | + | string secret_ctx = 14; | |
| 216 | 266 | } | |
| 217 | 267 | ||
| 218 | 268 | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | |
| @@ -213,6 +213,56 @@ message CIJob { | |||||
| 213 | // The job's concurrency group (its own, else the one of its run) and where | 213 | // The job's concurrency group (its own, else the one of its run) and where | ||
| 214 | // it is in it. | 214 | // it is in it. | ||
| 215 | CIConcurrency concurrency = 43; | 215 | CIConcurrency concurrency = 43; | ||
| 216 | + | // Reusable workflows (D7). scope is the id of the record of the job that | |||
| 217 | + | // called the workflow this job belongs to, empty for a job of the run's own | |||
| 218 | + | // workflow; call_id is the id of the job in that called workflow (key is | |||
| 219 | + | // "<caller key>/<call id>"). call is set on the record that stands for a job | |||
| 220 | + | // calling a reusable workflow: it never runs, it ends when the jobs of its | |||
| 221 | + | // scope have. | |||
| 222 | + | string scope = 44; | |||
| 223 | + | string call_id = 45; | |||
| 224 | + | CICall call = 46; | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // CICall is the call a reusable-workflow job makes, set when the calling job is | |||
| 228 | + | // planned. The called workflow's file is kept here, so that the jobs of the call | |||
| 229 | + | // are planned and run from the file of the commit that was resolved. | |||
| 230 | + | message CICall { | |||
| 231 | + | string uses = 1; | |||
| 232 | + | // path of the file in its repository, repository as "owner/repo" (github.com) | |||
| 233 | + | // or the name of the repository on this server, ref as the calling workflow | |||
| 234 | + | // wrote it ("" for a local call), sha the commit it resolved to. | |||
| 235 | + | string path = 2; | |||
| 236 | + | string repository = 3; | |||
| 237 | + | string ref = 4; | |||
| 238 | + | string sha = 5; | |||
| 239 | + | string yaml = 6; | |||
| 240 | + | // inputs_json is the inputs context of the called workflow: the values given | |||
| 241 | + | // and the defaults, typed. | |||
| 242 | + | string inputs_json = 7; | |||
| 243 | + | // secrets holds the names of the secrets of the called workflow that the | |||
| 244 | + | // calling workflow's "secrets:" gives (upper case), with an empty value: the | |||
| 245 | + | // expressions are not stored. They are read as template tokens from the pinned | |||
| 246 | + | // workflow file of the caller when a job is assigned, and evaluated once there. | |||
| 247 | + | map<string, string> secrets = 8; | |||
| 248 | + | bool inherit = 9; | |||
| 249 | + | int32 depth = 10; | |||
| 250 | + | // expanded: the jobs of the called workflow exist. | |||
| 251 | + | bool expanded = 11; | |||
| 252 | + | // concurrency is the workflow-level group of the called workflow, evaluated | |||
| 253 | + | // with the caller's github context and the call's inputs when the call is | |||
| 254 | + | // planned. The call record is its entry, like the group of the calling job | |||
| 255 | + | // (CIJob.concurrency): the jobs of the call run once the call holds both. | |||
| 256 | + | CIConcurrency concurrency = 12; | |||
| 257 | + | // admitted: the call may start (the max-parallel of the calling job's | |||
| 258 | + | // strategy allows it); it asks for its groups and plans its jobs only then. | |||
| 259 | + | bool admitted = 13; | |||
| 260 | + | // secret_ctx is the JSON of the contexts the "secrets:" expressions read apart | |||
| 261 | + | // from the secrets, as they were when the call was planned: needs, matrix, | |||
| 262 | + | // strategy, inputs and vars of the calling scope (none is secret). Plain data, | |||
| 263 | + | // never interpreted as a reference; the vars are the caller's, not those of | |||
| 264 | + | // the environment of the job that is assigned. | |||
| 265 | + | string secret_ctx = 14; | |||
| 216 | } | 266 | } | ||
| 217 | 267 | ||||
| 218 | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | 268 | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | ||