fix(ci): resolve actions of other repositories on behalf of the pusher, bind archive tokens to a commit
An action of another repository of the server was resolved for any run, and its archive token read the whole repository. Now the pusher of a run is recorded with it (root, owner, or the stored token by its id) and every answer asks whether that pusher can read the action's repository with the forge's own rule, looking the token up again, so a revoked or expired token, a repository made private, or a token whose list lacks the repository answers "repository not found", as for a repository that is not there. The archive token is a principal of its own: no scope, only the grant to download one commit of one repository. Only the archive endpoint reads it, and only for that commit; Git over HTTP, the repository and hash-algorithm endpoints and the API see a caller that reads nothing. Archive tokens are indexed by the repository they read and deleted with it. Also: a HEAD on the tarball resolves the ref and sends the headers without building the archive; archive downloads are limited per client with an idle write deadline; owner and repository names of an action use GitHub's charset; ls-remote is limited to four at once and a call to 60 s (unavailable); the owner is matched without regard to case in Git URLs, the REST subset and the web; the proxy variables reach ls-remote.
- commit
- 3d22057ff28fc642e218d86ea20d6ee33467bb89
- author
- brice · · 2026-10-13 11:21 UTC
- parent
- 2b9c4c6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
29 files · +1334 -123
M README.md +6 -2
| @@ -118,9 +118,13 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a | |||
| 118 | 118 | ||
| 119 | 119 | Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails. | |
| 120 | 120 | ||
| 121 | - | Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it. The runner also gets read tokens for the repositories of the actions a job uses from this server (an action of `ERMITE_OWNER/<repo>` on ermite wins over the same name on github.com). | |
| 121 | + | Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it. | |
| 122 | 122 | ||
| 123 | - | The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected). Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature. | |
| 123 | + | An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most three archives are written at once for one client address (`429` beyond), eight on the server, and a client that stops reading is cut after two minutes without progress. | |
| 124 | + | ||
| 125 | + | The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature. | |
| 126 | + | ||
| 127 | + | 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. | |
| 124 | 128 | ||
| 125 | 129 | `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. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. | |
| 126 | 130 | ||
| @@ -118,9 +118,13 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a | |||||
| 118 | 118 | ||||
| 119 | Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails. | 119 | Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails. | ||
| 120 | 120 | ||||
| 121 | - | Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it. The runner also gets read tokens for the repositories of the actions a job uses from this server (an action of `ERMITE_OWNER/<repo>` on ermite wins over the same name on github.com). | 121 | + | Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it. |
| 122 | 122 | ||||
| 123 | - | The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected). Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature. | 123 | + | An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most three archives are written at once for one client address (`429` beyond), eight on the server, and a client that stops reading is cut after two minutes without progress. |
| 124 | + | ||||
| 125 | + | The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature. | |||
| 126 | + | ||||
| 127 | + | 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. | |||
| 124 | 128 | ||||
| 125 | `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. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. | 129 | `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. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. | ||
| 126 | 130 | ||||
M gen/ermite/storage/ci.pb.go +45 -11
| @@ -580,7 +580,12 @@ type CIRunSource struct { | |||
| 580 | 580 | GithubJson string `protobuf:"bytes,2,opt,name=github_json,json=githubJson,proto3" json:"github_json,omitempty"` | |
| 581 | 581 | // Repository of the run: the key of the run record is run/<repo>/<id>, and | |
| 582 | 582 | // the runner side knows a run by its id only. | |
| 583 | - | Repo string `protobuf:"bytes,3,opt,name=repo,proto3" json:"repo,omitempty"` | |
| 583 | + | Repo string `protobuf:"bytes,3,opt,name=repo,proto3" json:"repo,omitempty"` | |
| 584 | + | // Who pushed the commit: "root", "owner", "token" or empty (a run stored | |
| 585 | + | // before it was recorded, or pushed by nobody). pusher_token_id is the ID of | |
| 586 | + | // the stored token for "token". What a token may read is looked up live. | |
| 587 | + | PusherKind string `protobuf:"bytes,5,opt,name=pusher_kind,json=pusherKind,proto3" json:"pusher_kind,omitempty"` | |
| 588 | + | PusherTokenId string `protobuf:"bytes,6,opt,name=pusher_token_id,json=pusherTokenId,proto3" json:"pusher_token_id,omitempty"` | |
| 584 | 589 | unknownFields protoimpl.UnknownFields | |
| 585 | 590 | sizeCache protoimpl.SizeCache | |
| 586 | 591 | } | |
| @@ -643,6 +648,20 @@ func (x *CIRunSource) GetRepo() string { | |||
| 643 | 648 | return "" | |
| 644 | 649 | } | |
| 645 | 650 | ||
| 651 | + | func (x *CIRunSource) GetPusherKind() string { | |
| 652 | + | if x != nil { | |
| 653 | + | return x.PusherKind | |
| 654 | + | } | |
| 655 | + | return "" | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | func (x *CIRunSource) GetPusherTokenId() string { | |
| 659 | + | if x != nil { | |
| 660 | + | return x.PusherTokenId | |
| 661 | + | } | |
| 662 | + | return "" | |
| 663 | + | } | |
| 664 | + | ||
| 646 | 665 | // CIStep is a step of a job. | |
| 647 | 666 | type CIStep struct { | |
| 648 | 667 | state protoimpl.MessageState `protogen:"open.v1"` | |
| @@ -1362,9 +1381,10 @@ func (x *CIRunner) GetTokenHash() []byte { | |||
| 1362 | 1381 | ||
| 1363 | 1382 | // CIRepoToken is a job repository token (the "github.token" of a job, or the | |
| 1364 | 1383 | // token of one action archive), stored under "ghtoken/<id>". The secret is | |
| 1365 | - | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and | |
| 1366 | - | // "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists | |
| 1367 | - | // the tokens of a job so that they are revoked together. | |
| 1384 | + | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, | |
| 1385 | + | // "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a | |
| 1386 | + | // job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the | |
| 1387 | + | // tokens that read a repository, so that they go with it. | |
| 1368 | 1388 | type CIRepoToken struct { | |
| 1369 | 1389 | state protoimpl.MessageState `protogen:"open.v1"` | |
| 1370 | 1390 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` | |
| @@ -1376,9 +1396,11 @@ type CIRepoToken struct { | |||
| 1376 | 1396 | RunAttempt int32 `protobuf:"varint,5,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | |
| 1377 | 1397 | JobId string `protobuf:"bytes,6,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` | |
| 1378 | 1398 | // Set for the token of an action archive, unset for the job's own token. | |
| 1379 | - | Archive bool `protobuf:"varint,7,opt,name=archive,proto3" json:"archive,omitempty"` | |
| 1380 | - | ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` | |
| 1381 | - | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 1399 | + | Archive bool `protobuf:"varint,7,opt,name=archive,proto3" json:"archive,omitempty"` | |
| 1400 | + | ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` | |
| 1401 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 1402 | + | // The commit an archive token downloads. | |
| 1403 | + | Sha string `protobuf:"bytes,10,opt,name=sha,proto3" json:"sha,omitempty"` | |
| 1382 | 1404 | unknownFields protoimpl.UnknownFields | |
| 1383 | 1405 | sizeCache protoimpl.SizeCache | |
| 1384 | 1406 | } | |
| @@ -1476,6 +1498,13 @@ func (x *CIRepoToken) GetCreatedAt() *timestamppb.Timestamp { | |||
| 1476 | 1498 | return nil | |
| 1477 | 1499 | } | |
| 1478 | 1500 | ||
| 1501 | + | func (x *CIRepoToken) GetSha() string { | |
| 1502 | + | if x != nil { | |
| 1503 | + | return x.Sha | |
| 1504 | + | } | |
| 1505 | + | return "" | |
| 1506 | + | } | |
| 1507 | + | ||
| 1479 | 1508 | // CIActionResolution is the resolution of an action reference for a run | |
| 1480 | 1509 | // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>" | |
| 1481 | 1510 | // so that every job of the attempt gets the same commit. | |
| @@ -1579,13 +1608,16 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1579 | 1608 | "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" + | |
| 1580 | 1609 | "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" + | |
| 1581 | 1610 | "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAt\x12\x12\n" + | |
| 1582 | - | "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"~\n" + | |
| 1611 | + | "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"\xc7\x01\n" + | |
| 1583 | 1612 | "\vCIRunSource\x12\x15\n" + | |
| 1584 | 1613 | "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" + | |
| 1585 | 1614 | "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" + | |
| 1586 | 1615 | "\vgithub_json\x18\x02 \x01(\tR\n" + | |
| 1587 | 1616 | "githubJson\x12\x12\n" + | |
| 1588 | - | "\x04repo\x18\x03 \x01(\tR\x04repo\"\xe5\x02\n" + | |
| 1617 | + | "\x04repo\x18\x03 \x01(\tR\x04repo\x12\x1f\n" + | |
| 1618 | + | "\vpusher_kind\x18\x05 \x01(\tR\n" + | |
| 1619 | + | "pusherKind\x12&\n" + | |
| 1620 | + | "\x0fpusher_token_id\x18\x06 \x01(\tR\rpusherTokenId\"\xe5\x02\n" + | |
| 1589 | 1621 | "\x06CIStep\x12\x16\n" + | |
| 1590 | 1622 | "\x06number\x18\x01 \x01(\x05R\x06number\x12\x12\n" + | |
| 1591 | 1623 | "\x04name\x18\x02 \x01(\tR\x04name\x121\n" + | |
| @@ -1682,7 +1714,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1682 | 1714 | "\x02os\x18\a \x01(\tR\x02os\x12?\n" + | |
| 1683 | 1715 | "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" + | |
| 1684 | 1716 | "\n" + | |
| 1685 | - | "token_hash\x18\t \x01(\fR\ttokenHash\"\xa4\x02\n" + | |
| 1717 | + | "token_hash\x18\t \x01(\fR\ttokenHash\"\xb6\x02\n" + | |
| 1686 | 1718 | "\vCIRepoToken\x12\x0e\n" + | |
| 1687 | 1719 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + | |
| 1688 | 1720 | "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" + | |
| @@ -1695,7 +1727,9 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1695 | 1727 | "\n" + | |
| 1696 | 1728 | "expires_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" + | |
| 1697 | 1729 | "\n" + | |
| 1698 | - | "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\x89\x01\n" + | |
| 1730 | + | "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x10\n" + | |
| 1731 | + | "\x03sha\x18\n" + | |
| 1732 | + | " \x01(\tR\x03sha\"\x89\x01\n" + | |
| 1699 | 1733 | "\x12CIActionResolution\x12\x10\n" + | |
| 1700 | 1734 | "\x03key\x18\x01 \x01(\tR\x03key\x12&\n" + | |
| 1701 | 1735 | "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12!\n" + | |
| @@ -580,7 +580,12 @@ type CIRunSource struct { | |||||
| 580 | GithubJson string `protobuf:"bytes,2,opt,name=github_json,json=githubJson,proto3" json:"github_json,omitempty"` | 580 | GithubJson string `protobuf:"bytes,2,opt,name=github_json,json=githubJson,proto3" json:"github_json,omitempty"` | ||
| 581 | // Repository of the run: the key of the run record is run/<repo>/<id>, and | 581 | // Repository of the run: the key of the run record is run/<repo>/<id>, and | ||
| 582 | // the runner side knows a run by its id only. | 582 | // the runner side knows a run by its id only. | ||
| 583 | - | Repo string `protobuf:"bytes,3,opt,name=repo,proto3" json:"repo,omitempty"` | 583 | + | Repo string `protobuf:"bytes,3,opt,name=repo,proto3" json:"repo,omitempty"` |
| 584 | + | // Who pushed the commit: "root", "owner", "token" or empty (a run stored | |||
| 585 | + | // before it was recorded, or pushed by nobody). pusher_token_id is the ID of | |||
| 586 | + | // the stored token for "token". What a token may read is looked up live. | |||
| 587 | + | PusherKind string `protobuf:"bytes,5,opt,name=pusher_kind,json=pusherKind,proto3" json:"pusher_kind,omitempty"` | |||
| 588 | + | PusherTokenId string `protobuf:"bytes,6,opt,name=pusher_token_id,json=pusherTokenId,proto3" json:"pusher_token_id,omitempty"` | |||
| 584 | unknownFields protoimpl.UnknownFields | 589 | unknownFields protoimpl.UnknownFields | ||
| 585 | sizeCache protoimpl.SizeCache | 590 | sizeCache protoimpl.SizeCache | ||
| 586 | } | 591 | } | ||
| @@ -643,6 +648,20 @@ func (x *CIRunSource) GetRepo() string { | |||||
| 643 | return "" | 648 | return "" | ||
| 644 | } | 649 | } | ||
| 645 | 650 | ||||
| 651 | + | func (x *CIRunSource) GetPusherKind() string { | |||
| 652 | + | if x != nil { | |||
| 653 | + | return x.PusherKind | |||
| 654 | + | } | |||
| 655 | + | return "" | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | func (x *CIRunSource) GetPusherTokenId() string { | |||
| 659 | + | if x != nil { | |||
| 660 | + | return x.PusherTokenId | |||
| 661 | + | } | |||
| 662 | + | return "" | |||
| 663 | + | } | |||
| 664 | + | ||||
| 646 | // CIStep is a step of a job. | 665 | // CIStep is a step of a job. | ||
| 647 | type CIStep struct { | 666 | type CIStep struct { | ||
| 648 | state protoimpl.MessageState `protogen:"open.v1"` | 667 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| @@ -1362,9 +1381,10 @@ func (x *CIRunner) GetTokenHash() []byte { | |||||
| 1362 | 1381 | ||||
| 1363 | // CIRepoToken is a job repository token (the "github.token" of a job, or the | 1382 | // CIRepoToken is a job repository token (the "github.token" of a job, or the | ||
| 1364 | // token of one action archive), stored under "ghtoken/<id>". The secret is | 1383 | // token of one action archive), stored under "ghtoken/<id>". The secret is | ||
| 1365 | - | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and | 1384 | + | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, |
| 1366 | - | // "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists | 1385 | + | // "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a |
| 1367 | - | // the tokens of a job so that they are revoked together. | 1386 | + | // job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the |
| 1387 | + | // tokens that read a repository, so that they go with it. | |||
| 1368 | type CIRepoToken struct { | 1388 | type CIRepoToken struct { | ||
| 1369 | state protoimpl.MessageState `protogen:"open.v1"` | 1389 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 1370 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` | 1390 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` | ||
| @@ -1376,9 +1396,11 @@ type CIRepoToken struct { | |||||
| 1376 | RunAttempt int32 `protobuf:"varint,5,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | 1396 | RunAttempt int32 `protobuf:"varint,5,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | ||
| 1377 | JobId string `protobuf:"bytes,6,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` | 1397 | JobId string `protobuf:"bytes,6,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` | ||
| 1378 | // Set for the token of an action archive, unset for the job's own token. | 1398 | // Set for the token of an action archive, unset for the job's own token. | ||
| 1379 | - | Archive bool `protobuf:"varint,7,opt,name=archive,proto3" json:"archive,omitempty"` | 1399 | + | Archive bool `protobuf:"varint,7,opt,name=archive,proto3" json:"archive,omitempty"` |
| 1380 | - | ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` | 1400 | + | ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` |
| 1381 | - | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | 1401 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` |
| 1402 | + | // The commit an archive token downloads. | |||
| 1403 | + | Sha string `protobuf:"bytes,10,opt,name=sha,proto3" json:"sha,omitempty"` | |||
| 1382 | unknownFields protoimpl.UnknownFields | 1404 | unknownFields protoimpl.UnknownFields | ||
| 1383 | sizeCache protoimpl.SizeCache | 1405 | sizeCache protoimpl.SizeCache | ||
| 1384 | } | 1406 | } | ||
| @@ -1476,6 +1498,13 @@ func (x *CIRepoToken) GetCreatedAt() *timestamppb.Timestamp { | |||||
| 1476 | return nil | 1498 | return nil | ||
| 1477 | } | 1499 | } | ||
| 1478 | 1500 | ||||
| 1501 | + | func (x *CIRepoToken) GetSha() string { | |||
| 1502 | + | if x != nil { | |||
| 1503 | + | return x.Sha | |||
| 1504 | + | } | |||
| 1505 | + | return "" | |||
| 1506 | + | } | |||
| 1507 | + | ||||
| 1479 | // CIActionResolution is the resolution of an action reference for a run | 1508 | // CIActionResolution is the resolution of an action reference for a run | ||
| 1480 | // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>" | 1509 | // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>" | ||
| 1481 | // so that every job of the attempt gets the same commit. | 1510 | // so that every job of the attempt gets the same commit. | ||
| @@ -1579,13 +1608,16 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||||
| 1579 | "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" + | 1608 | "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" + | ||
| 1580 | "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" + | 1609 | "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" + | ||
| 1581 | "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAt\x12\x12\n" + | 1610 | "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAt\x12\x12\n" + | ||
| 1582 | - | "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"~\n" + | 1611 | + | "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"\xc7\x01\n" + |
| 1583 | "\vCIRunSource\x12\x15\n" + | 1612 | "\vCIRunSource\x12\x15\n" + | ||
| 1584 | "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" + | 1613 | "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" + | ||
| 1585 | "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" + | 1614 | "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" + | ||
| 1586 | "\vgithub_json\x18\x02 \x01(\tR\n" + | 1615 | "\vgithub_json\x18\x02 \x01(\tR\n" + | ||
| 1587 | "githubJson\x12\x12\n" + | 1616 | "githubJson\x12\x12\n" + | ||
| 1588 | - | "\x04repo\x18\x03 \x01(\tR\x04repo\"\xe5\x02\n" + | 1617 | + | "\x04repo\x18\x03 \x01(\tR\x04repo\x12\x1f\n" + |
| 1618 | + | "\vpusher_kind\x18\x05 \x01(\tR\n" + | |||
| 1619 | + | "pusherKind\x12&\n" + | |||
| 1620 | + | "\x0fpusher_token_id\x18\x06 \x01(\tR\rpusherTokenId\"\xe5\x02\n" + | |||
| 1589 | "\x06CIStep\x12\x16\n" + | 1621 | "\x06CIStep\x12\x16\n" + | ||
| 1590 | "\x06number\x18\x01 \x01(\x05R\x06number\x12\x12\n" + | 1622 | "\x06number\x18\x01 \x01(\x05R\x06number\x12\x12\n" + | ||
| 1591 | "\x04name\x18\x02 \x01(\tR\x04name\x121\n" + | 1623 | "\x04name\x18\x02 \x01(\tR\x04name\x121\n" + | ||
| @@ -1682,7 +1714,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||||
| 1682 | "\x02os\x18\a \x01(\tR\x02os\x12?\n" + | 1714 | "\x02os\x18\a \x01(\tR\x02os\x12?\n" + | ||
| 1683 | "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" + | 1715 | "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" + | ||
| 1684 | "\n" + | 1716 | "\n" + | ||
| 1685 | - | "token_hash\x18\t \x01(\fR\ttokenHash\"\xa4\x02\n" + | 1717 | + | "token_hash\x18\t \x01(\fR\ttokenHash\"\xb6\x02\n" + |
| 1686 | "\vCIRepoToken\x12\x0e\n" + | 1718 | "\vCIRepoToken\x12\x0e\n" + | ||
| 1687 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + | 1719 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + | ||
| 1688 | "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" + | 1720 | "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" + | ||
| @@ -1695,7 +1727,9 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||||
| 1695 | "\n" + | 1727 | "\n" + | ||
| 1696 | "expires_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" + | 1728 | "expires_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" + | ||
| 1697 | "\n" + | 1729 | "\n" + | ||
| 1698 | - | "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\x89\x01\n" + | 1730 | + | "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x10\n" + |
| 1731 | + | "\x03sha\x18\n" + | |||
| 1732 | + | " \x01(\tR\x03sha\"\x89\x01\n" + | |||
| 1699 | "\x12CIActionResolution\x12\x10\n" + | 1733 | "\x12CIActionResolution\x12\x10\n" + | ||
| 1700 | "\x03key\x18\x01 \x01(\tR\x03key\x12&\n" + | 1734 | "\x03key\x18\x01 \x01(\tR\x03key\x12&\n" + | ||
| 1701 | "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12!\n" + | 1735 | "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12!\n" + | ||
M internal/adapters/githttp/handler.go +9 -5
| @@ -42,8 +42,10 @@ var Patterns = []string{ | |||
| 42 | 42 | // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones | |
| 43 | 43 | // <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal | |
| 44 | 44 | // segment so that the patterns never overlap the /-/ routes, and other owners | |
| 45 | - | // fall through to the web, which answers 404. Register them only for a valid | |
| 46 | - | // owner; Config.Owner must be the same. | |
| 45 | + | // fall through to the web, which answers 404. The handler matches the owner | |
| 46 | + | // without regard to case, as GitHub does, but the mux only the exact spelling | |
| 47 | + | // of ERMITE_OWNER (lower case, like a repository name); register them only for | |
| 48 | + | // a valid owner and set Config.Owner to the same. | |
| 47 | 49 | func OwnerPatterns(owner string) []string { | |
| 48 | 50 | out := make([]string, len(Patterns)) | |
| 49 | 51 | for i, p := range Patterns { | |
| @@ -176,8 +178,8 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) { | |||
| 176 | 178 | p := strings.TrimPrefix(r.URL.Path, "/") | |
| 177 | 179 | var head, rest string | |
| 178 | 180 | for _, suffix := range gitSuffixes { | |
| 179 | - | if h, ok := strings.CutSuffix(p, suffix); ok { | |
| 180 | - | head, rest = h, suffix[1:] | |
| 181 | + | if trimmed, ok := strings.CutSuffix(p, suffix); ok { | |
| 182 | + | head, rest = trimmed, suffix[1:] | |
| 181 | 183 | break | |
| 182 | 184 | } | |
| 183 | 185 | } | |
| @@ -186,7 +188,9 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) { | |||
| 186 | 188 | } | |
| 187 | 189 | seg := head | |
| 188 | 190 | if owner, repo, ok := strings.Cut(head, "/"); ok { | |
| 189 | - | if h.cfg.Owner == "" || owner != h.cfg.Owner { | |
| 191 | + | // The owner is matched as GitHub does, without regard to case, like the | |
| 192 | + | // REST subset and the references of actions (domain.OwnerMatches). | |
| 193 | + | if !domain.OwnerMatches(h.cfg.Owner, owner) { | |
| 190 | 194 | return route{}, http.StatusNotFound | |
| 191 | 195 | } | |
| 192 | 196 | seg = repo | |
| @@ -42,8 +42,10 @@ var Patterns = []string{ | |||||
| 42 | // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones | 42 | // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones | ||
| 43 | // <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal | 43 | // <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal | ||
| 44 | // segment so that the patterns never overlap the /-/ routes, and other owners | 44 | // segment so that the patterns never overlap the /-/ routes, and other owners | ||
| 45 | - | // fall through to the web, which answers 404. Register them only for a valid | 45 | + | // fall through to the web, which answers 404. The handler matches the owner |
| 46 | - | // owner; Config.Owner must be the same. | 46 | + | // without regard to case, as GitHub does, but the mux only the exact spelling |
| 47 | + | // of ERMITE_OWNER (lower case, like a repository name); register them only for | |||
| 48 | + | // a valid owner and set Config.Owner to the same. | |||
| 47 | func OwnerPatterns(owner string) []string { | 49 | func OwnerPatterns(owner string) []string { | ||
| 48 | out := make([]string, len(Patterns)) | 50 | out := make([]string, len(Patterns)) | ||
| 49 | for i, p := range Patterns { | 51 | for i, p := range Patterns { | ||
| @@ -176,8 +178,8 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) { | |||||
| 176 | p := strings.TrimPrefix(r.URL.Path, "/") | 178 | p := strings.TrimPrefix(r.URL.Path, "/") | ||
| 177 | var head, rest string | 179 | var head, rest string | ||
| 178 | for _, suffix := range gitSuffixes { | 180 | for _, suffix := range gitSuffixes { | ||
| 179 | - | if h, ok := strings.CutSuffix(p, suffix); ok { | 181 | + | if trimmed, ok := strings.CutSuffix(p, suffix); ok { |
| 180 | - | head, rest = h, suffix[1:] | 182 | + | head, rest = trimmed, suffix[1:] |
| 181 | break | 183 | break | ||
| 182 | } | 184 | } | ||
| 183 | } | 185 | } | ||
| @@ -186,7 +188,9 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) { | |||||
| 186 | } | 188 | } | ||
| 187 | seg := head | 189 | seg := head | ||
| 188 | if owner, repo, ok := strings.Cut(head, "/"); ok { | 190 | if owner, repo, ok := strings.Cut(head, "/"); ok { | ||
| 189 | - | if h.cfg.Owner == "" || owner != h.cfg.Owner { | 191 | + | // The owner is matched as GitHub does, without regard to case, like the |
| 192 | + | // REST subset and the references of actions (domain.OwnerMatches). | |||
| 193 | + | if !domain.OwnerMatches(h.cfg.Owner, owner) { | |||
| 190 | return route{}, http.StatusNotFound | 194 | return route{}, http.StatusNotFound | ||
| 191 | } | 195 | } | ||
| 192 | seg = repo | 196 | seg = repo | ||
M internal/adapters/githttp/handler_test.go +3 -2
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "net/netip" | |
| 12 | 12 | "os" | |
| 13 | 13 | "path/filepath" | |
| 14 | + | "slices" | |
| 14 | 15 | "strings" | |
| 15 | 16 | "sync" | |
| 16 | 17 | "testing" | |
| @@ -188,7 +189,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |||
| 188 | 189 | } | |
| 189 | 190 | r.h = githttp.New(cfg) | |
| 190 | 191 | mux := http.NewServeMux() | |
| 191 | - | for _, p := range append(githttp.Patterns, githttp.OwnerPatterns("acme")...) { | |
| 192 | + | for _, p := range slices.Concat(githttp.Patterns, githttp.OwnerPatterns("acme"), githttp.OwnerPatterns("ACME")) { | |
| 192 | 193 | mux.Handle(p, r.h) | |
| 193 | 194 | } | |
| 194 | 195 | r.srv = httptest.NewServer(mux) | |
| @@ -522,7 +523,7 @@ func TestHandlerServesTheOwnerForm(t *testing.T) { | |||
| 522 | 523 | {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200}, | |
| 523 | 524 | {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200}, | |
| 524 | 525 | {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 525 | - | {"owner case is exact", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 526 | + | {"owner case does not matter", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 200}, | |
| 526 | 527 | {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 527 | 528 | {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 528 | 529 | {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404}, | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "net/netip" | 11 | "net/netip" | ||
| 12 | "os" | 12 | "os" | ||
| 13 | "path/filepath" | 13 | "path/filepath" | ||
| 14 | + | "slices" | |||
| 14 | "strings" | 15 | "strings" | ||
| 15 | "sync" | 16 | "sync" | ||
| 16 | "testing" | 17 | "testing" | ||
| @@ -188,7 +189,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |||||
| 188 | } | 189 | } | ||
| 189 | r.h = githttp.New(cfg) | 190 | r.h = githttp.New(cfg) | ||
| 190 | mux := http.NewServeMux() | 191 | mux := http.NewServeMux() | ||
| 191 | - | for _, p := range append(githttp.Patterns, githttp.OwnerPatterns("acme")...) { | 192 | + | for _, p := range slices.Concat(githttp.Patterns, githttp.OwnerPatterns("acme"), githttp.OwnerPatterns("ACME")) { |
| 192 | mux.Handle(p, r.h) | 193 | mux.Handle(p, r.h) | ||
| 193 | } | 194 | } | ||
| 194 | r.srv = httptest.NewServer(mux) | 195 | r.srv = httptest.NewServer(mux) | ||
| @@ -522,7 +523,7 @@ func TestHandlerServesTheOwnerForm(t *testing.T) { | |||||
| 522 | {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200}, | 523 | {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200}, | ||
| 523 | {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200}, | 524 | {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200}, | ||
| 524 | {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | 525 | {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | ||
| 525 | - | {"owner case is exact", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | 526 | + | {"owner case does not matter", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 200}, |
| 526 | {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404}, | 527 | {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404}, | ||
| 527 | {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404}, | 528 | {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404}, | ||
| 528 | {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404}, | 529 | {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404}, | ||
M internal/adapters/githubapi/handler.go +93 -24
| @@ -16,6 +16,7 @@ | |||
| 16 | 16 | package githubapi | |
| 17 | 17 | ||
| 18 | 18 | import ( | |
| 19 | + | "cmp" | |
| 19 | 20 | "context" | |
| 20 | 21 | "errors" | |
| 21 | 22 | "io" | |
| @@ -23,6 +24,8 @@ import ( | |||
| 23 | 24 | "net/netip" | |
| 24 | 25 | "strconv" | |
| 25 | 26 | "strings" | |
| 27 | + | "sync" | |
| 28 | + | "time" | |
| 26 | 29 | ||
| 27 | 30 | "github.com/bnema/zerowrap" | |
| 28 | 31 | ||
| @@ -40,8 +43,19 @@ var Patterns = []string{Prefix + "/"} | |||
| 40 | 43 | // notFoundBody is GitHub's body for an unknown endpoint or repository. | |
| 41 | 44 | const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | |
| 42 | 45 | ||
| 43 | - | // DefaultMaxArchives bounds the archives written at once. | |
| 44 | - | const DefaultMaxArchives = 8 | |
| 46 | + | // Bounds on the archives written at once. | |
| 47 | + | const ( | |
| 48 | + | // DefaultMaxArchives is the archives written at once on the server. | |
| 49 | + | DefaultMaxArchives = 8 | |
| 50 | + | // DefaultMaxArchivesPerClient is the archives written at once for one client | |
| 51 | + | // (see clientlimit.Key): a runner downloads the actions of a job one by one, | |
| 52 | + | // so a few are plenty, and one client cannot take every slot. | |
| 53 | + | DefaultMaxArchivesPerClient = 3 | |
| 54 | + | // DefaultWriteIdleTimeout is how long a write of the archive may wait for the | |
| 55 | + | // client. The deadline moves forward with every chunk: an archive is bounded | |
| 56 | + | // by its size, not by its duration, but a client that stops reading is cut. | |
| 57 | + | DefaultWriteIdleTimeout = 2 * time.Minute | |
| 58 | + | ) | |
| 45 | 59 | ||
| 46 | 60 | // Config configures a Handler. | |
| 47 | 61 | type Config struct { | |
| @@ -52,8 +66,12 @@ type Config struct { | |||
| 52 | 66 | // Limiter counts failed authentications; nil builds a default one. | |
| 53 | 67 | Limiter ports.FailureLimiter | |
| 54 | 68 | // MaxArchives bounds the archives written at once (each runs a git | |
| 55 | - | // process); zero means DefaultMaxArchives. | |
| 56 | - | MaxArchives int | |
| 69 | + | // process); zero means DefaultMaxArchives. MaxArchivesPerClient bounds them | |
| 70 | + | // per client; zero means DefaultMaxArchivesPerClient. WriteIdleTimeout is | |
| 71 | + | // the idle write deadline; zero means DefaultWriteIdleTimeout. | |
| 72 | + | MaxArchives int | |
| 73 | + | MaxArchivesPerClient int | |
| 74 | + | WriteIdleTimeout time.Duration | |
| 57 | 75 | } | |
| 58 | 76 | ||
| 59 | 77 | // Handler serves the REST subset. | |
| @@ -61,6 +79,11 @@ type Handler struct { | |||
| 61 | 79 | cfg Config | |
| 62 | 80 | limiter ports.FailureLimiter | |
| 63 | 81 | archives chan struct{} | |
| 82 | + | perCli int | |
| 83 | + | idle time.Duration | |
| 84 | + | ||
| 85 | + | mu sync.Mutex // guards clients | |
| 86 | + | clients map[string]int // archives being written, per clientlimit.Key | |
| 64 | 87 | } | |
| 65 | 88 | ||
| 66 | 89 | // New builds a Handler. | |
| @@ -74,6 +97,9 @@ func New(cfg Config) *Handler { | |||
| 74 | 97 | n = DefaultMaxArchives | |
| 75 | 98 | } | |
| 76 | 99 | h.archives = make(chan struct{}, n) | |
| 100 | + | h.perCli = cmp.Or(cfg.MaxArchivesPerClient, DefaultMaxArchivesPerClient) | |
| 101 | + | h.idle = cmp.Or(cfg.WriteIdleTimeout, DefaultWriteIdleTimeout) | |
| 102 | + | h.clients = make(map[string]int) | |
| 77 | 103 | return h | |
| 78 | 104 | } | |
| 79 | 105 | ||
| @@ -127,7 +153,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 127 | 153 | } | |
| 128 | 154 | writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`)) | |
| 129 | 155 | case "tarball": | |
| 130 | - | h.tarball(w, r, p, owner, repo, ref) | |
| 156 | + | h.tarball(w, r, p, owner, repo, ref, clientlimit.Key(addr)) | |
| 131 | 157 | } | |
| 132 | 158 | } | |
| 133 | 159 | ||
| @@ -150,35 +176,33 @@ func route(rest string) (kind, owner, repo, ref string, ok bool) { | |||
| 150 | 176 | return "", "", "", "", false | |
| 151 | 177 | } | |
| 152 | 178 | ||
| 153 | - | // tarball streams the archive. It is not a 302 to a codeload: ermite serves the | |
| 154 | - | // bytes itself, which the runner follows without a second credential. | |
| 155 | - | func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref string) { | |
| 179 | + | // tarball answers an archive request. A HEAD resolves the ref and sends the | |
| 180 | + | // headers of the archive, and runs no git. A GET streams the archive; it is not | |
| 181 | + | // a 302 to a codeload: ermite serves the bytes itself, which the runner follows | |
| 182 | + | // without a second credential. | |
| 183 | + | func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref, client string) { | |
| 156 | 184 | ctx := r.Context() | |
| 157 | - | select { | |
| 158 | - | case h.archives <- struct{}{}: | |
| 159 | - | defer func() { <-h.archives }() | |
| 160 | - | default: | |
| 161 | - | w.Header().Set("Retry-After", "5") | |
| 162 | - | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |
| 163 | - | return | |
| 164 | - | } | |
| 165 | 185 | if r.Method == http.MethodHead { | |
| 166 | - | // Resolve without writing: a HEAD is answered with the headers only. | |
| 167 | - | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | |
| 168 | - | setArchiveHeaders(w, name) | |
| 169 | - | w.WriteHeader(http.StatusOK) | |
| 170 | - | return io.Discard | |
| 171 | - | }) | |
| 172 | - | if err != nil && !errors.Is(err, context.Canceled) { | |
| 186 | + | name, err := h.cfg.API.TarballName(ctx, p, owner, repo, ref) | |
| 187 | + | if err != nil { | |
| 173 | 188 | h.fail(ctx, w, err) | |
| 189 | + | return | |
| 174 | 190 | } | |
| 191 | + | setArchiveHeaders(w, name) | |
| 192 | + | w.WriteHeader(http.StatusOK) | |
| 175 | 193 | return | |
| 176 | 194 | } | |
| 195 | + | release, ok := h.acquire(w, client) | |
| 196 | + | if !ok { | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | defer release() | |
| 200 | + | rc := http.NewResponseController(w) | |
| 177 | 201 | started := false | |
| 178 | 202 | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | |
| 179 | 203 | started = true | |
| 180 | 204 | setArchiveHeaders(w, name) | |
| 181 | - | return w | |
| 205 | + | return &idleWriter{w: w, rc: rc, idle: h.idle} | |
| 182 | 206 | }) | |
| 183 | 207 | switch { | |
| 184 | 208 | case err == nil: | |
| @@ -191,6 +215,51 @@ func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Princ | |||
| 191 | 215 | } | |
| 192 | 216 | } | |
| 193 | 217 | ||
| 218 | + | // acquire takes an archive slot for the client, or answers 429 (the client | |
| 219 | + | // already has its share) or 503 (the server writes all it can) and reports | |
| 220 | + | // false. It never waits: an archive can take minutes. | |
| 221 | + | func (h *Handler) acquire(w http.ResponseWriter, client string) (release func(), ok bool) { | |
| 222 | + | h.mu.Lock() | |
| 223 | + | if h.clients[client] >= h.perCli { | |
| 224 | + | h.mu.Unlock() | |
| 225 | + | w.Header().Set("Retry-After", "5") | |
| 226 | + | jsonError(w, http.StatusTooManyRequests, "Too many archives in progress") | |
| 227 | + | return nil, false | |
| 228 | + | } | |
| 229 | + | h.clients[client]++ | |
| 230 | + | h.mu.Unlock() | |
| 231 | + | releaseClient := func() { | |
| 232 | + | h.mu.Lock() | |
| 233 | + | if h.clients[client]--; h.clients[client] <= 0 { | |
| 234 | + | delete(h.clients, client) | |
| 235 | + | } | |
| 236 | + | h.mu.Unlock() | |
| 237 | + | } | |
| 238 | + | select { | |
| 239 | + | case h.archives <- struct{}{}: | |
| 240 | + | return func() { <-h.archives; releaseClient() }, true | |
| 241 | + | default: | |
| 242 | + | releaseClient() | |
| 243 | + | w.Header().Set("Retry-After", "5") | |
| 244 | + | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |
| 245 | + | return nil, false | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // idleWriter moves the write deadline of the connection forward before each | |
| 250 | + | // chunk, so that only a client that stops reading is cut. Where the response | |
| 251 | + | // writer cannot set a deadline, the writes are not bounded here. | |
| 252 | + | type idleWriter struct { | |
| 253 | + | w io.Writer | |
| 254 | + | rc *http.ResponseController | |
| 255 | + | idle time.Duration | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (i *idleWriter) Write(b []byte) (int, error) { | |
| 259 | + | _ = i.rc.SetWriteDeadline(time.Now().Add(i.idle)) | |
| 260 | + | return i.w.Write(b) | |
| 261 | + | } | |
| 262 | + | ||
| 194 | 263 | func setArchiveHeaders(w http.ResponseWriter, name string) { | |
| 195 | 264 | w.Header().Set("Content-Type", "application/x-gzip") | |
| 196 | 265 | w.Header().Set("Content-Disposition", "attachment; filename="+name) | |
| @@ -16,6 +16,7 @@ | |||||
| 16 | package githubapi | 16 | package githubapi | ||
| 17 | 17 | ||||
| 18 | import ( | 18 | import ( | ||
| 19 | + | "cmp" | |||
| 19 | "context" | 20 | "context" | ||
| 20 | "errors" | 21 | "errors" | ||
| 21 | "io" | 22 | "io" | ||
| @@ -23,6 +24,8 @@ import ( | |||||
| 23 | "net/netip" | 24 | "net/netip" | ||
| 24 | "strconv" | 25 | "strconv" | ||
| 25 | "strings" | 26 | "strings" | ||
| 27 | + | "sync" | |||
| 28 | + | "time" | |||
| 26 | 29 | ||||
| 27 | "github.com/bnema/zerowrap" | 30 | "github.com/bnema/zerowrap" | ||
| 28 | 31 | ||||
| @@ -40,8 +43,19 @@ var Patterns = []string{Prefix + "/"} | |||||
| 40 | // notFoundBody is GitHub's body for an unknown endpoint or repository. | 43 | // notFoundBody is GitHub's body for an unknown endpoint or repository. | ||
| 41 | const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | 44 | const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | ||
| 42 | 45 | ||||
| 43 | - | // DefaultMaxArchives bounds the archives written at once. | 46 | + | // Bounds on the archives written at once. |
| 44 | - | const DefaultMaxArchives = 8 | 47 | + | const ( |
| 48 | + | // DefaultMaxArchives is the archives written at once on the server. | |||
| 49 | + | DefaultMaxArchives = 8 | |||
| 50 | + | // DefaultMaxArchivesPerClient is the archives written at once for one client | |||
| 51 | + | // (see clientlimit.Key): a runner downloads the actions of a job one by one, | |||
| 52 | + | // so a few are plenty, and one client cannot take every slot. | |||
| 53 | + | DefaultMaxArchivesPerClient = 3 | |||
| 54 | + | // DefaultWriteIdleTimeout is how long a write of the archive may wait for the | |||
| 55 | + | // client. The deadline moves forward with every chunk: an archive is bounded | |||
| 56 | + | // by its size, not by its duration, but a client that stops reading is cut. | |||
| 57 | + | DefaultWriteIdleTimeout = 2 * time.Minute | |||
| 58 | + | ) | |||
| 45 | 59 | ||||
| 46 | // Config configures a Handler. | 60 | // Config configures a Handler. | ||
| 47 | type Config struct { | 61 | type Config struct { | ||
| @@ -52,8 +66,12 @@ type Config struct { | |||||
| 52 | // Limiter counts failed authentications; nil builds a default one. | 66 | // Limiter counts failed authentications; nil builds a default one. | ||
| 53 | Limiter ports.FailureLimiter | 67 | Limiter ports.FailureLimiter | ||
| 54 | // MaxArchives bounds the archives written at once (each runs a git | 68 | // MaxArchives bounds the archives written at once (each runs a git | ||
| 55 | - | // process); zero means DefaultMaxArchives. | 69 | + | // process); zero means DefaultMaxArchives. MaxArchivesPerClient bounds them |
| 56 | - | MaxArchives int | 70 | + | // per client; zero means DefaultMaxArchivesPerClient. WriteIdleTimeout is |
| 71 | + | // the idle write deadline; zero means DefaultWriteIdleTimeout. | |||
| 72 | + | MaxArchives int | |||
| 73 | + | MaxArchivesPerClient int | |||
| 74 | + | WriteIdleTimeout time.Duration | |||
| 57 | } | 75 | } | ||
| 58 | 76 | ||||
| 59 | // Handler serves the REST subset. | 77 | // Handler serves the REST subset. | ||
| @@ -61,6 +79,11 @@ type Handler struct { | |||||
| 61 | cfg Config | 79 | cfg Config | ||
| 62 | limiter ports.FailureLimiter | 80 | limiter ports.FailureLimiter | ||
| 63 | archives chan struct{} | 81 | archives chan struct{} | ||
| 82 | + | perCli int | |||
| 83 | + | idle time.Duration | |||
| 84 | + | ||||
| 85 | + | mu sync.Mutex // guards clients | |||
| 86 | + | clients map[string]int // archives being written, per clientlimit.Key | |||
| 64 | } | 87 | } | ||
| 65 | 88 | ||||
| 66 | // New builds a Handler. | 89 | // New builds a Handler. | ||
| @@ -74,6 +97,9 @@ func New(cfg Config) *Handler { | |||||
| 74 | n = DefaultMaxArchives | 97 | n = DefaultMaxArchives | ||
| 75 | } | 98 | } | ||
| 76 | h.archives = make(chan struct{}, n) | 99 | h.archives = make(chan struct{}, n) | ||
| 100 | + | h.perCli = cmp.Or(cfg.MaxArchivesPerClient, DefaultMaxArchivesPerClient) | |||
| 101 | + | h.idle = cmp.Or(cfg.WriteIdleTimeout, DefaultWriteIdleTimeout) | |||
| 102 | + | h.clients = make(map[string]int) | |||
| 77 | return h | 103 | return h | ||
| 78 | } | 104 | } | ||
| 79 | 105 | ||||
| @@ -127,7 +153,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 127 | } | 153 | } | ||
| 128 | writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`)) | 154 | writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`)) | ||
| 129 | case "tarball": | 155 | case "tarball": | ||
| 130 | - | h.tarball(w, r, p, owner, repo, ref) | 156 | + | h.tarball(w, r, p, owner, repo, ref, clientlimit.Key(addr)) |
| 131 | } | 157 | } | ||
| 132 | } | 158 | } | ||
| 133 | 159 | ||||
| @@ -150,35 +176,33 @@ func route(rest string) (kind, owner, repo, ref string, ok bool) { | |||||
| 150 | return "", "", "", "", false | 176 | return "", "", "", "", false | ||
| 151 | } | 177 | } | ||
| 152 | 178 | ||||
| 153 | - | // tarball streams the archive. It is not a 302 to a codeload: ermite serves the | 179 | + | // tarball answers an archive request. A HEAD resolves the ref and sends the |
| 154 | - | // bytes itself, which the runner follows without a second credential. | 180 | + | // headers of the archive, and runs no git. A GET streams the archive; it is not |
| 155 | - | func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref string) { | 181 | + | // a 302 to a codeload: ermite serves the bytes itself, which the runner follows |
| 182 | + | // without a second credential. | |||
| 183 | + | func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref, client string) { | |||
| 156 | ctx := r.Context() | 184 | ctx := r.Context() | ||
| 157 | - | select { | |||
| 158 | - | case h.archives <- struct{}{}: | |||
| 159 | - | defer func() { <-h.archives }() | |||
| 160 | - | default: | |||
| 161 | - | w.Header().Set("Retry-After", "5") | |||
| 162 | - | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |||
| 163 | - | return | |||
| 164 | - | } | |||
| 165 | if r.Method == http.MethodHead { | 185 | if r.Method == http.MethodHead { | ||
| 166 | - | // Resolve without writing: a HEAD is answered with the headers only. | 186 | + | name, err := h.cfg.API.TarballName(ctx, p, owner, repo, ref) |
| 167 | - | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | 187 | + | if err != nil { |
| 168 | - | setArchiveHeaders(w, name) | |||
| 169 | - | w.WriteHeader(http.StatusOK) | |||
| 170 | - | return io.Discard | |||
| 171 | - | }) | |||
| 172 | - | if err != nil && !errors.Is(err, context.Canceled) { | |||
| 173 | h.fail(ctx, w, err) | 188 | h.fail(ctx, w, err) | ||
| 189 | + | return | |||
| 174 | } | 190 | } | ||
| 191 | + | setArchiveHeaders(w, name) | |||
| 192 | + | w.WriteHeader(http.StatusOK) | |||
| 175 | return | 193 | return | ||
| 176 | } | 194 | } | ||
| 195 | + | release, ok := h.acquire(w, client) | |||
| 196 | + | if !ok { | |||
| 197 | + | return | |||
| 198 | + | } | |||
| 199 | + | defer release() | |||
| 200 | + | rc := http.NewResponseController(w) | |||
| 177 | started := false | 201 | started := false | ||
| 178 | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | 202 | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | ||
| 179 | started = true | 203 | started = true | ||
| 180 | setArchiveHeaders(w, name) | 204 | setArchiveHeaders(w, name) | ||
| 181 | - | return w | 205 | + | return &idleWriter{w: w, rc: rc, idle: h.idle} |
| 182 | }) | 206 | }) | ||
| 183 | switch { | 207 | switch { | ||
| 184 | case err == nil: | 208 | case err == nil: | ||
| @@ -191,6 +215,51 @@ func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Princ | |||||
| 191 | } | 215 | } | ||
| 192 | } | 216 | } | ||
| 193 | 217 | ||||
| 218 | + | // acquire takes an archive slot for the client, or answers 429 (the client | |||
| 219 | + | // already has its share) or 503 (the server writes all it can) and reports | |||
| 220 | + | // false. It never waits: an archive can take minutes. | |||
| 221 | + | func (h *Handler) acquire(w http.ResponseWriter, client string) (release func(), ok bool) { | |||
| 222 | + | h.mu.Lock() | |||
| 223 | + | if h.clients[client] >= h.perCli { | |||
| 224 | + | h.mu.Unlock() | |||
| 225 | + | w.Header().Set("Retry-After", "5") | |||
| 226 | + | jsonError(w, http.StatusTooManyRequests, "Too many archives in progress") | |||
| 227 | + | return nil, false | |||
| 228 | + | } | |||
| 229 | + | h.clients[client]++ | |||
| 230 | + | h.mu.Unlock() | |||
| 231 | + | releaseClient := func() { | |||
| 232 | + | h.mu.Lock() | |||
| 233 | + | if h.clients[client]--; h.clients[client] <= 0 { | |||
| 234 | + | delete(h.clients, client) | |||
| 235 | + | } | |||
| 236 | + | h.mu.Unlock() | |||
| 237 | + | } | |||
| 238 | + | select { | |||
| 239 | + | case h.archives <- struct{}{}: | |||
| 240 | + | return func() { <-h.archives; releaseClient() }, true | |||
| 241 | + | default: | |||
| 242 | + | releaseClient() | |||
| 243 | + | w.Header().Set("Retry-After", "5") | |||
| 244 | + | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |||
| 245 | + | return nil, false | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // idleWriter moves the write deadline of the connection forward before each | |||
| 250 | + | // chunk, so that only a client that stops reading is cut. Where the response | |||
| 251 | + | // writer cannot set a deadline, the writes are not bounded here. | |||
| 252 | + | type idleWriter struct { | |||
| 253 | + | w io.Writer | |||
| 254 | + | rc *http.ResponseController | |||
| 255 | + | idle time.Duration | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func (i *idleWriter) Write(b []byte) (int, error) { | |||
| 259 | + | _ = i.rc.SetWriteDeadline(time.Now().Add(i.idle)) | |||
| 260 | + | return i.w.Write(b) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 194 | func setArchiveHeaders(w http.ResponseWriter, name string) { | 263 | func setArchiveHeaders(w http.ResponseWriter, name string) { | ||
| 195 | w.Header().Set("Content-Type", "application/x-gzip") | 264 | w.Header().Set("Content-Type", "application/x-gzip") | ||
| 196 | w.Header().Set("Content-Disposition", "attachment; filename="+name) | 265 | w.Header().Set("Content-Disposition", "attachment; filename="+name) | ||
M internal/adapters/gitremote/gitremote.go +24 -0
| @@ -45,6 +45,29 @@ type Config struct { | |||
| 45 | 45 | MaxOutput int | |
| 46 | 46 | // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default. | |
| 47 | 47 | AllowedProtocols string | |
| 48 | + | // Proxy is the proxy environment forwarded to git, as NAME=value pairs: see | |
| 49 | + | // ProxyEnv. Nothing else of the environment of ermite reaches git. | |
| 50 | + | Proxy []string | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // proxyVars are the variables git (through libcurl) reads to reach github.com | |
| 54 | + | // through a proxy. They are the only ones of the environment of ermite that | |
| 55 | + | // the ls-remote processes get. | |
| 56 | + | var proxyVars = []string{ | |
| 57 | + | "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY", | |
| 58 | + | "https_proxy", "http_proxy", "no_proxy", "all_proxy", | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // ProxyEnv picks the proxy variables from an environment lookup (os.Getenv), | |
| 62 | + | // for Config.Proxy. Empty ones are left out. | |
| 63 | + | func ProxyEnv(get func(string) string) []string { | |
| 64 | + | var out []string | |
| 65 | + | for _, name := range proxyVars { | |
| 66 | + | if v := get(name); v != "" { | |
| 67 | + | out = append(out, name+"="+v) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | return out | |
| 48 | 71 | } | |
| 49 | 72 | ||
| 50 | 73 | // Remote implements ports.GitRemote over the git binary. | |
| @@ -121,6 +144,7 @@ func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.Remo | |||
| 121 | 144 | "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never", | |
| 122 | 145 | "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path, | |
| 123 | 146 | } | |
| 147 | + | cmd.Env = append(cmd.Env, r.cfg.Proxy...) | |
| 124 | 148 | cmd.Dir = r.cfg.Home | |
| 125 | 149 | cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096} | |
| 126 | 150 | cmd.WaitDelay = 2 * time.Second | |
| @@ -45,6 +45,29 @@ type Config struct { | |||||
| 45 | MaxOutput int | 45 | MaxOutput int | ||
| 46 | // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default. | 46 | // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default. | ||
| 47 | AllowedProtocols string | 47 | AllowedProtocols string | ||
| 48 | + | // Proxy is the proxy environment forwarded to git, as NAME=value pairs: see | |||
| 49 | + | // ProxyEnv. Nothing else of the environment of ermite reaches git. | |||
| 50 | + | Proxy []string | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // proxyVars are the variables git (through libcurl) reads to reach github.com | |||
| 54 | + | // through a proxy. They are the only ones of the environment of ermite that | |||
| 55 | + | // the ls-remote processes get. | |||
| 56 | + | var proxyVars = []string{ | |||
| 57 | + | "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY", | |||
| 58 | + | "https_proxy", "http_proxy", "no_proxy", "all_proxy", | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // ProxyEnv picks the proxy variables from an environment lookup (os.Getenv), | |||
| 62 | + | // for Config.Proxy. Empty ones are left out. | |||
| 63 | + | func ProxyEnv(get func(string) string) []string { | |||
| 64 | + | var out []string | |||
| 65 | + | for _, name := range proxyVars { | |||
| 66 | + | if v := get(name); v != "" { | |||
| 67 | + | out = append(out, name+"="+v) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | return out | |||
| 48 | } | 71 | } | ||
| 49 | 72 | ||||
| 50 | // Remote implements ports.GitRemote over the git binary. | 73 | // Remote implements ports.GitRemote over the git binary. | ||
| @@ -121,6 +144,7 @@ func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.Remo | |||||
| 121 | "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never", | 144 | "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never", | ||
| 122 | "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path, | 145 | "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path, | ||
| 123 | } | 146 | } | ||
| 147 | + | cmd.Env = append(cmd.Env, r.cfg.Proxy...) | |||
| 124 | cmd.Dir = r.cfg.Home | 148 | cmd.Dir = r.cfg.Home | ||
| 125 | cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096} | 149 | cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096} | ||
| 126 | cmd.WaitDelay = 2 * time.Second | 150 | cmd.WaitDelay = 2 * time.Second | ||
M internal/adapters/gitremote/gitremote_test.go +31 -0
| @@ -146,3 +146,34 @@ func TestListRefsGitEnvironment(t *testing.T) { | |||
| 146 | 146 | assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*") | |
| 147 | 147 | assert.Contains(t, env, "credential.helper=") | |
| 148 | 148 | } | |
| 149 | + | ||
| 150 | + | // GO-008: the proxy variables of ermite reach git, and nothing else of its | |
| 151 | + | // environment does. | |
| 152 | + | func TestListRefsForwardsTheProxyEnvironment(t *testing.T) { | |
| 153 | + | out := filepath.Join(t.TempDir(), "env") | |
| 154 | + | bin := filepath.Join(t.TempDir(), "git") | |
| 155 | + | require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755)) | |
| 156 | + | t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128") | |
| 157 | + | t.Setenv("no_proxy", "localhost,.internal") | |
| 158 | + | t.Setenv("HTTP_PROXY", "") | |
| 159 | + | t.Setenv("GITHUB_TOKEN", "not-for-git") | |
| 160 | + | ||
| 161 | + | proxy := gitremote.ProxyEnv(os.Getenv) | |
| 162 | + | assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out") | |
| 163 | + | ||
| 164 | + | _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r") | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | b, err := os.ReadFile(out) | |
| 167 | + | require.NoError(t, err) | |
| 168 | + | env := string(b) | |
| 169 | + | assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128") | |
| 170 | + | assert.Contains(t, env, "no_proxy=localhost,.internal") | |
| 171 | + | assert.NotContains(t, env, "not-for-git") | |
| 172 | + | assert.NotContains(t, env, "HTTP_PROXY=") | |
| 173 | + | ||
| 174 | + | // Without Config.Proxy nothing is forwarded, whatever the process has. | |
| 175 | + | _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r") | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | b, _ = os.ReadFile(out) | |
| 178 | + | assert.NotContains(t, string(b), "proxy.internal") | |
| 179 | + | } | |
| @@ -146,3 +146,34 @@ func TestListRefsGitEnvironment(t *testing.T) { | |||||
| 146 | assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*") | 146 | assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*") | ||
| 147 | assert.Contains(t, env, "credential.helper=") | 147 | assert.Contains(t, env, "credential.helper=") | ||
| 148 | } | 148 | } | ||
| 149 | + | ||||
| 150 | + | // GO-008: the proxy variables of ermite reach git, and nothing else of its | |||
| 151 | + | // environment does. | |||
| 152 | + | func TestListRefsForwardsTheProxyEnvironment(t *testing.T) { | |||
| 153 | + | out := filepath.Join(t.TempDir(), "env") | |||
| 154 | + | bin := filepath.Join(t.TempDir(), "git") | |||
| 155 | + | require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755)) | |||
| 156 | + | t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128") | |||
| 157 | + | t.Setenv("no_proxy", "localhost,.internal") | |||
| 158 | + | t.Setenv("HTTP_PROXY", "") | |||
| 159 | + | t.Setenv("GITHUB_TOKEN", "not-for-git") | |||
| 160 | + | ||||
| 161 | + | proxy := gitremote.ProxyEnv(os.Getenv) | |||
| 162 | + | assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out") | |||
| 163 | + | ||||
| 164 | + | _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r") | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | b, err := os.ReadFile(out) | |||
| 167 | + | require.NoError(t, err) | |||
| 168 | + | env := string(b) | |||
| 169 | + | assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128") | |||
| 170 | + | assert.Contains(t, env, "no_proxy=localhost,.internal") | |||
| 171 | + | assert.NotContains(t, env, "not-for-git") | |||
| 172 | + | assert.NotContains(t, env, "HTTP_PROXY=") | |||
| 173 | + | ||||
| 174 | + | // Without Config.Proxy nothing is forwarded, whatever the process has. | |||
| 175 | + | _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r") | |||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | b, _ = os.ReadFile(out) | |||
| 178 | + | assert.NotContains(t, string(b), "proxy.internal") | |||
| 179 | + | } | |||
M internal/adapters/kvstore/ci.go +6 -1
| @@ -590,6 +590,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||
| 590 | 590 | } | |
| 591 | 591 | src := mustMarshal(&storagepb.CIRunSource{ | |
| 592 | 592 | RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON, | |
| 593 | + | PusherKind: string(draft.Source.Pusher.Kind), PusherTokenId: draft.Source.Pusher.TokenID, | |
| 593 | 594 | }) | |
| 594 | 595 | b.RequireAbsent(runSrcKey(draft.Run.ID)) | |
| 595 | 596 | b.Put(runSrcKey(draft.Run.ID), src) | |
| @@ -642,7 +643,10 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, | |||
| 642 | 643 | if err != nil { | |
| 643 | 644 | return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err) | |
| 644 | 645 | } | |
| 645 | - | return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil | |
| 646 | + | return domain.CIRunSource{ | |
| 647 | + | RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson(), | |
| 648 | + | Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()}, | |
| 649 | + | }, nil | |
| 646 | 650 | } | |
| 647 | 651 | ||
| 648 | 652 | // ListRuns returns the runs of the repository, newest first. | |
| @@ -856,6 +860,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by | |||
| 856 | 860 | } | |
| 857 | 861 | keys = append(keys, slices.Clone(key)) | |
| 858 | 862 | } | |
| 863 | + | keys = append(keys, repoReaderKeys(ctx, snap, repo)...) | |
| 859 | 864 | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} { | |
| 860 | 865 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 861 | 866 | keys = append(keys, slices.Clone(k)) | |
| @@ -590,6 +590,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||||
| 590 | } | 590 | } | ||
| 591 | src := mustMarshal(&storagepb.CIRunSource{ | 591 | src := mustMarshal(&storagepb.CIRunSource{ | ||
| 592 | RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON, | 592 | RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON, | ||
| 593 | + | PusherKind: string(draft.Source.Pusher.Kind), PusherTokenId: draft.Source.Pusher.TokenID, | |||
| 593 | }) | 594 | }) | ||
| 594 | b.RequireAbsent(runSrcKey(draft.Run.ID)) | 595 | b.RequireAbsent(runSrcKey(draft.Run.ID)) | ||
| 595 | b.Put(runSrcKey(draft.Run.ID), src) | 596 | b.Put(runSrcKey(draft.Run.ID), src) | ||
| @@ -642,7 +643,10 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, | |||||
| 642 | if err != nil { | 643 | if err != nil { | ||
| 643 | return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err) | 644 | return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err) | ||
| 644 | } | 645 | } | ||
| 645 | - | return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil | 646 | + | return domain.CIRunSource{ |
| 647 | + | RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson(), | |||
| 648 | + | Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()}, | |||
| 649 | + | }, nil | |||
| 646 | } | 650 | } | ||
| 647 | 651 | ||||
| 648 | // ListRuns returns the runs of the repository, newest first. | 652 | // ListRuns returns the runs of the repository, newest first. | ||
| @@ -856,6 +860,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by | |||||
| 856 | } | 860 | } | ||
| 857 | keys = append(keys, slices.Clone(key)) | 861 | keys = append(keys, slices.Clone(key)) | ||
| 858 | } | 862 | } | ||
| 863 | + | keys = append(keys, repoReaderKeys(ctx, snap, repo)...) | |||
| 859 | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} { | 864 | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} { | ||
| 860 | for k := range snap.Prefix(ctx, []byte(p)) { | 865 | for k := range snap.Prefix(ctx, []byte(p)) { | ||
| 861 | keys = append(keys, slices.Clone(k)) | 866 | keys = append(keys, slices.Clone(k)) | ||
M internal/adapters/kvstore/ci_token.go +39 -8
| @@ -22,7 +22,8 @@ import ( | |||
| 22 | 22 | // | |
| 23 | 23 | // ghtoken/<id> CIRepoToken | |
| 24 | 24 | // ghtokenhash/<hash> token id | |
| 25 | - | // ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> empty: the tokens of a job | |
| 25 | + | // ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> the id: the tokens of a job | |
| 26 | + | // ghtokenrepo/<repo>/<id> the job key: the tokens that read a repository | |
| 26 | 27 | // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution | |
| 27 | 28 | // | |
| 28 | 29 | // Stored tokens use token/ and tokenhash/: the two families never meet, so a | |
| @@ -31,6 +32,7 @@ const ( | |||
| 31 | 32 | repoTokenPrefix = "ghtoken/" | |
| 32 | 33 | repoTokenHashPrefix = "ghtokenhash/" | |
| 33 | 34 | repoTokenJobPrefix = "ghtokenjob/" | |
| 35 | + | repoTokenRepoPrefix = "ghtokenrepo/" | |
| 34 | 36 | resolutionPrefix = "actres/" | |
| 35 | 37 | ) | |
| 36 | 38 | ||
| @@ -46,6 +48,12 @@ func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string { | |||
| 46 | 48 | func repoTokenJobKey(t domain.CIRepoToken) []byte { | |
| 47 | 49 | return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID) | |
| 48 | 50 | } | |
| 51 | + | func repoTokenRepoPrefixOf(repo domain.RepoName) string { | |
| 52 | + | return repoTokenRepoPrefix + repo.String() + "/" | |
| 53 | + | } | |
| 54 | + | func repoTokenRepoKey(repo domain.RepoName, id string) []byte { | |
| 55 | + | return []byte(repoTokenRepoPrefixOf(repo) + id) | |
| 56 | + | } | |
| 49 | 57 | func resolutionKey(runID int64, attempt int32, key string) []byte { | |
| 50 | 58 | sum := sha256.Sum256([]byte(key)) | |
| 51 | 59 | return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:]))) | |
| @@ -54,7 +62,7 @@ func resolutionKey(runID int64, attempt int32, key string) []byte { | |||
| 54 | 62 | func encodeRepoToken(t domain.CIRepoToken) []byte { | |
| 55 | 63 | return mustMarshal(&storagepb.CIRepoToken{ | |
| 56 | 64 | Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID, | |
| 57 | - | Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), | |
| 65 | + | Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, | |
| 58 | 66 | }) | |
| 59 | 67 | } | |
| 60 | 68 | ||
| @@ -70,6 +78,7 @@ func decodeRepoToken(val []byte) (domain.CIRepoToken, error) { | |||
| 70 | 78 | t := domain.CIRepoToken{ | |
| 71 | 79 | ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(), | |
| 72 | 80 | Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 81 | + | Sha: pb.GetSha(), | |
| 73 | 82 | } | |
| 74 | 83 | copy(t.Hash[:], pb.GetHash()) | |
| 75 | 84 | return t, nil | |
| @@ -90,6 +99,7 @@ func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, | |||
| 90 | 99 | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | |
| 91 | 100 | if t, err := decodeRepoToken(it.Value); err == nil { | |
| 92 | 101 | b.Delete(repoTokenHashKey(t.Hash)) | |
| 102 | + | b.Delete(repoTokenRepoKey(t.Repo, id)) | |
| 93 | 103 | } | |
| 94 | 104 | } | |
| 95 | 105 | } | |
| @@ -100,6 +110,7 @@ func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, | |||
| 100 | 110 | b.Put(repoTokenKey(t.ID), encodeRepoToken(t)) | |
| 101 | 111 | b.Put(repoTokenHashKey(t.Hash), []byte(t.ID)) | |
| 102 | 112 | b.Put(repoTokenJobKey(t), []byte(t.ID)) | |
| 113 | + | b.Put(repoTokenRepoKey(t.Repo, t.ID), []byte(t.ID)) | |
| 103 | 114 | guarded[t.Repo] = true | |
| 104 | 115 | } | |
| 105 | 116 | for _, r := range w.Resolutions { | |
| @@ -158,20 +169,40 @@ func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32 | |||
| 158 | 169 | }, nil | |
| 159 | 170 | } | |
| 160 | 171 | ||
| 172 | + | // tokenKeys lists the keys of the token with that id: the record, its hash | |
| 173 | + | // index, its entry in the list of its job and in the list of its repository. | |
| 174 | + | func tokenKeys(ctx context.Context, snap *kv.Snapshot, id string) [][]byte { | |
| 175 | + | keys := [][]byte{repoTokenKey(id)} | |
| 176 | + | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | |
| 177 | + | if t, err := decodeRepoToken(it.Value); err == nil { | |
| 178 | + | keys = append(keys, repoTokenHashKey(t.Hash), repoTokenJobKey(t), repoTokenRepoKey(t.Repo, id)) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | return keys | |
| 182 | + | } | |
| 183 | + | ||
| 161 | 184 | // repoTokenKeys lists the keys of the repository tokens and action | |
| 162 | 185 | // resolutions of a run, for its deletion. | |
| 163 | 186 | func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte { | |
| 164 | 187 | var keys [][]byte | |
| 165 | 188 | for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) { | |
| 166 | - | keys = append(keys, append([]byte(nil), key...), repoTokenKey(string(item.Value))) | |
| 167 | - | if it, ok := snap.Get(ctx, repoTokenKey(string(item.Value))); ok { | |
| 168 | - | if t, err := decodeRepoToken(it.Value); err == nil { | |
| 169 | - | keys = append(keys, repoTokenHashKey(t.Hash)) | |
| 170 | - | } | |
| 171 | - | } | |
| 189 | + | keys = append(keys, append([]byte(nil), key...)) | |
| 190 | + | keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...) | |
| 172 | 191 | } | |
| 173 | 192 | for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) { | |
| 174 | 193 | keys = append(keys, append([]byte(nil), key...)) | |
| 175 | 194 | } | |
| 176 | 195 | return keys | |
| 177 | 196 | } | |
| 197 | + | ||
| 198 | + | // repoReaderKeys lists the keys of every repository token that reads the | |
| 199 | + | // repository, whichever job holds it: the archive tokens given to the jobs of | |
| 200 | + | // other repositories go with the repository they read. | |
| 201 | + | func repoReaderKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 202 | + | var keys [][]byte | |
| 203 | + | for key, item := range snap.Prefix(ctx, []byte(repoTokenRepoPrefixOf(repo))) { | |
| 204 | + | keys = append(keys, append([]byte(nil), key...)) | |
| 205 | + | keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...) | |
| 206 | + | } | |
| 207 | + | return keys | |
| 208 | + | } | |
| @@ -22,7 +22,8 @@ import ( | |||||
| 22 | // | 22 | // | ||
| 23 | // ghtoken/<id> CIRepoToken | 23 | // ghtoken/<id> CIRepoToken | ||
| 24 | // ghtokenhash/<hash> token id | 24 | // ghtokenhash/<hash> token id | ||
| 25 | - | // ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> empty: the tokens of a job | 25 | + | // ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> the id: the tokens of a job |
| 26 | + | // ghtokenrepo/<repo>/<id> the job key: the tokens that read a repository | |||
| 26 | // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution | 27 | // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution | ||
| 27 | // | 28 | // | ||
| 28 | // Stored tokens use token/ and tokenhash/: the two families never meet, so a | 29 | // Stored tokens use token/ and tokenhash/: the two families never meet, so a | ||
| @@ -31,6 +32,7 @@ const ( | |||||
| 31 | repoTokenPrefix = "ghtoken/" | 32 | repoTokenPrefix = "ghtoken/" | ||
| 32 | repoTokenHashPrefix = "ghtokenhash/" | 33 | repoTokenHashPrefix = "ghtokenhash/" | ||
| 33 | repoTokenJobPrefix = "ghtokenjob/" | 34 | repoTokenJobPrefix = "ghtokenjob/" | ||
| 35 | + | repoTokenRepoPrefix = "ghtokenrepo/" | |||
| 34 | resolutionPrefix = "actres/" | 36 | resolutionPrefix = "actres/" | ||
| 35 | ) | 37 | ) | ||
| 36 | 38 | ||||
| @@ -46,6 +48,12 @@ func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string { | |||||
| 46 | func repoTokenJobKey(t domain.CIRepoToken) []byte { | 48 | func repoTokenJobKey(t domain.CIRepoToken) []byte { | ||
| 47 | return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID) | 49 | return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID) | ||
| 48 | } | 50 | } | ||
| 51 | + | func repoTokenRepoPrefixOf(repo domain.RepoName) string { | |||
| 52 | + | return repoTokenRepoPrefix + repo.String() + "/" | |||
| 53 | + | } | |||
| 54 | + | func repoTokenRepoKey(repo domain.RepoName, id string) []byte { | |||
| 55 | + | return []byte(repoTokenRepoPrefixOf(repo) + id) | |||
| 56 | + | } | |||
| 49 | func resolutionKey(runID int64, attempt int32, key string) []byte { | 57 | func resolutionKey(runID int64, attempt int32, key string) []byte { | ||
| 50 | sum := sha256.Sum256([]byte(key)) | 58 | sum := sha256.Sum256([]byte(key)) | ||
| 51 | return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:]))) | 59 | return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:]))) | ||
| @@ -54,7 +62,7 @@ func resolutionKey(runID int64, attempt int32, key string) []byte { | |||||
| 54 | func encodeRepoToken(t domain.CIRepoToken) []byte { | 62 | func encodeRepoToken(t domain.CIRepoToken) []byte { | ||
| 55 | return mustMarshal(&storagepb.CIRepoToken{ | 63 | return mustMarshal(&storagepb.CIRepoToken{ | ||
| 56 | Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID, | 64 | Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID, | ||
| 57 | - | Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), | 65 | + | Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, |
| 58 | }) | 66 | }) | ||
| 59 | } | 67 | } | ||
| 60 | 68 | ||||
| @@ -70,6 +78,7 @@ func decodeRepoToken(val []byte) (domain.CIRepoToken, error) { | |||||
| 70 | t := domain.CIRepoToken{ | 78 | t := domain.CIRepoToken{ | ||
| 71 | ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(), | 79 | ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(), | ||
| 72 | Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()), | 80 | Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()), | ||
| 81 | + | Sha: pb.GetSha(), | |||
| 73 | } | 82 | } | ||
| 74 | copy(t.Hash[:], pb.GetHash()) | 83 | copy(t.Hash[:], pb.GetHash()) | ||
| 75 | return t, nil | 84 | return t, nil | ||
| @@ -90,6 +99,7 @@ func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, | |||||
| 90 | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | 99 | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | ||
| 91 | if t, err := decodeRepoToken(it.Value); err == nil { | 100 | if t, err := decodeRepoToken(it.Value); err == nil { | ||
| 92 | b.Delete(repoTokenHashKey(t.Hash)) | 101 | b.Delete(repoTokenHashKey(t.Hash)) | ||
| 102 | + | b.Delete(repoTokenRepoKey(t.Repo, id)) | |||
| 93 | } | 103 | } | ||
| 94 | } | 104 | } | ||
| 95 | } | 105 | } | ||
| @@ -100,6 +110,7 @@ func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, | |||||
| 100 | b.Put(repoTokenKey(t.ID), encodeRepoToken(t)) | 110 | b.Put(repoTokenKey(t.ID), encodeRepoToken(t)) | ||
| 101 | b.Put(repoTokenHashKey(t.Hash), []byte(t.ID)) | 111 | b.Put(repoTokenHashKey(t.Hash), []byte(t.ID)) | ||
| 102 | b.Put(repoTokenJobKey(t), []byte(t.ID)) | 112 | b.Put(repoTokenJobKey(t), []byte(t.ID)) | ||
| 113 | + | b.Put(repoTokenRepoKey(t.Repo, t.ID), []byte(t.ID)) | |||
| 103 | guarded[t.Repo] = true | 114 | guarded[t.Repo] = true | ||
| 104 | } | 115 | } | ||
| 105 | for _, r := range w.Resolutions { | 116 | for _, r := range w.Resolutions { | ||
| @@ -158,20 +169,40 @@ func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32 | |||||
| 158 | }, nil | 169 | }, nil | ||
| 159 | } | 170 | } | ||
| 160 | 171 | ||||
| 172 | + | // tokenKeys lists the keys of the token with that id: the record, its hash | |||
| 173 | + | // index, its entry in the list of its job and in the list of its repository. | |||
| 174 | + | func tokenKeys(ctx context.Context, snap *kv.Snapshot, id string) [][]byte { | |||
| 175 | + | keys := [][]byte{repoTokenKey(id)} | |||
| 176 | + | if it, ok := snap.Get(ctx, repoTokenKey(id)); ok { | |||
| 177 | + | if t, err := decodeRepoToken(it.Value); err == nil { | |||
| 178 | + | keys = append(keys, repoTokenHashKey(t.Hash), repoTokenJobKey(t), repoTokenRepoKey(t.Repo, id)) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | return keys | |||
| 182 | + | } | |||
| 183 | + | ||||
| 161 | // repoTokenKeys lists the keys of the repository tokens and action | 184 | // repoTokenKeys lists the keys of the repository tokens and action | ||
| 162 | // resolutions of a run, for its deletion. | 185 | // resolutions of a run, for its deletion. | ||
| 163 | func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte { | 186 | func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte { | ||
| 164 | var keys [][]byte | 187 | var keys [][]byte | ||
| 165 | for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) { | 188 | for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) { | ||
| 166 | - | keys = append(keys, append([]byte(nil), key...), repoTokenKey(string(item.Value))) | 189 | + | keys = append(keys, append([]byte(nil), key...)) |
| 167 | - | if it, ok := snap.Get(ctx, repoTokenKey(string(item.Value))); ok { | 190 | + | keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...) |
| 168 | - | if t, err := decodeRepoToken(it.Value); err == nil { | |||
| 169 | - | keys = append(keys, repoTokenHashKey(t.Hash)) | |||
| 170 | - | } | |||
| 171 | - | } | |||
| 172 | } | 191 | } | ||
| 173 | for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) { | 192 | for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) { | ||
| 174 | keys = append(keys, append([]byte(nil), key...)) | 193 | keys = append(keys, append([]byte(nil), key...)) | ||
| 175 | } | 194 | } | ||
| 176 | return keys | 195 | return keys | ||
| 177 | } | 196 | } | ||
| 197 | + | ||||
| 198 | + | // repoReaderKeys lists the keys of every repository token that reads the | |||
| 199 | + | // repository, whichever job holds it: the archive tokens given to the jobs of | |||
| 200 | + | // other repositories go with the repository they read. | |||
| 201 | + | func repoReaderKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |||
| 202 | + | var keys [][]byte | |||
| 203 | + | for key, item := range snap.Prefix(ctx, []byte(repoTokenRepoPrefixOf(repo))) { | |||
| 204 | + | keys = append(keys, append([]byte(nil), key...)) | |||
| 205 | + | keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...) | |||
| 206 | + | } | |||
| 207 | + | return keys | |||
| 208 | + | } | |||
M internal/adapters/kvstore/ci_token_test.go +53 -0
| @@ -140,3 +140,56 @@ type jobRefT struct { | |||
| 140 | 140 | RunID int64 | |
| 141 | 141 | JobID string | |
| 142 | 142 | } | |
| 143 | + | ||
| 144 | + | // GO-006: an archive token is indexed by the repository it reads, so deleting | |
| 145 | + | // that repository deletes it, though it belongs to a job of another one. | |
| 146 | + | func TestCIArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) { | |
| 147 | + | ctx := context.Background() | |
| 148 | + | s, ci, run := ciRepo(t) | |
| 149 | + | target := name(t, "action") | |
| 150 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: target, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |
| 151 | + | d, err := ci.CreateRun(ctx, run, "w.yml", draftOf(run, "w.yml", 1)) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID} | |
| 154 | + | ||
| 155 | + | own := repoToken("own", "ermitegh_own", ref, run, false) | |
| 156 | + | arch := repoToken("arch", "ermitegh_arch", ref, target, true) | |
| 157 | + | arch.Sha = "1111111111111111111111111111111111111111" | |
| 158 | + | _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch}}) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | got, err := ci.FindRepoTokenByHash(ctx, arch.Hash) | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | assert.Equal(t, arch.Sha, got.Sha, "the commit is stored") | |
| 163 | + | assert.True(t, got.Archive) | |
| 164 | + | ||
| 165 | + | require.NoError(t, s.Delete(ctx, target)) | |
| 166 | + | _, err = ci.FindRepoTokenByHash(ctx, arch.Hash) | |
| 167 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "gone with the repository it reads") | |
| 168 | + | _, err = ci.GetRepoToken(ctx, "arch") | |
| 169 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 170 | + | _, err = ci.FindRepoTokenByHash(ctx, own.Hash) | |
| 171 | + | assert.NoError(t, err, "the token of the run's own repository is untouched") | |
| 172 | + | ||
| 173 | + | // Revoking the job afterwards finds no stale entry to trip on. | |
| 174 | + | _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: ref.JobID}}}) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | _, err = ci.FindRepoTokenByHash(ctx, own.Hash) | |
| 177 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // GO-001: the pusher recorded with a run comes back with its source. | |
| 181 | + | func TestCIRunSourceKeepsThePusher(t *testing.T) { | |
| 182 | + | ctx := context.Background() | |
| 183 | + | _, ci, n := ciRepo(t) | |
| 184 | + | for i, p := range []domain.CIPusher{{Kind: domain.CIPusherRoot}, {Kind: domain.CIPusherOwner}, {Kind: domain.CIPusherToken, TokenID: "abc123"}, {}} { | |
| 185 | + | d, err := ci.CreateRun(ctx, n, "w.yml", func(id, number int64) ports.CIRunDraft { | |
| 186 | + | dr := draftOf(n, "w.yml", 0)(id, number) | |
| 187 | + | dr.Source.Pusher = p | |
| 188 | + | return dr | |
| 189 | + | }) | |
| 190 | + | require.NoError(t, err, i) | |
| 191 | + | src, err := ci.GetRunSource(ctx, d.Run.ID) | |
| 192 | + | require.NoError(t, err) | |
| 193 | + | assert.Equal(t, p, src.Pusher) | |
| 194 | + | } | |
| 195 | + | } | |
| @@ -140,3 +140,56 @@ type jobRefT struct { | |||||
| 140 | RunID int64 | 140 | RunID int64 | ||
| 141 | JobID string | 141 | JobID string | ||
| 142 | } | 142 | } | ||
| 143 | + | ||||
| 144 | + | // GO-006: an archive token is indexed by the repository it reads, so deleting | |||
| 145 | + | // that repository deletes it, though it belongs to a job of another one. | |||
| 146 | + | func TestCIArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) { | |||
| 147 | + | ctx := context.Background() | |||
| 148 | + | s, ci, run := ciRepo(t) | |||
| 149 | + | target := name(t, "action") | |||
| 150 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: target, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |||
| 151 | + | d, err := ci.CreateRun(ctx, run, "w.yml", draftOf(run, "w.yml", 1)) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID} | |||
| 154 | + | ||||
| 155 | + | own := repoToken("own", "ermitegh_own", ref, run, false) | |||
| 156 | + | arch := repoToken("arch", "ermitegh_arch", ref, target, true) | |||
| 157 | + | arch.Sha = "1111111111111111111111111111111111111111" | |||
| 158 | + | _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch}}) | |||
| 159 | + | require.NoError(t, err) | |||
| 160 | + | got, err := ci.FindRepoTokenByHash(ctx, arch.Hash) | |||
| 161 | + | require.NoError(t, err) | |||
| 162 | + | assert.Equal(t, arch.Sha, got.Sha, "the commit is stored") | |||
| 163 | + | assert.True(t, got.Archive) | |||
| 164 | + | ||||
| 165 | + | require.NoError(t, s.Delete(ctx, target)) | |||
| 166 | + | _, err = ci.FindRepoTokenByHash(ctx, arch.Hash) | |||
| 167 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "gone with the repository it reads") | |||
| 168 | + | _, err = ci.GetRepoToken(ctx, "arch") | |||
| 169 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 170 | + | _, err = ci.FindRepoTokenByHash(ctx, own.Hash) | |||
| 171 | + | assert.NoError(t, err, "the token of the run's own repository is untouched") | |||
| 172 | + | ||||
| 173 | + | // Revoking the job afterwards finds no stale entry to trip on. | |||
| 174 | + | _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: ref.JobID}}}) | |||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | _, err = ci.FindRepoTokenByHash(ctx, own.Hash) | |||
| 177 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // GO-001: the pusher recorded with a run comes back with its source. | |||
| 181 | + | func TestCIRunSourceKeepsThePusher(t *testing.T) { | |||
| 182 | + | ctx := context.Background() | |||
| 183 | + | _, ci, n := ciRepo(t) | |||
| 184 | + | for i, p := range []domain.CIPusher{{Kind: domain.CIPusherRoot}, {Kind: domain.CIPusherOwner}, {Kind: domain.CIPusherToken, TokenID: "abc123"}, {}} { | |||
| 185 | + | d, err := ci.CreateRun(ctx, n, "w.yml", func(id, number int64) ports.CIRunDraft { | |||
| 186 | + | dr := draftOf(n, "w.yml", 0)(id, number) | |||
| 187 | + | dr.Source.Pusher = p | |||
| 188 | + | return dr | |||
| 189 | + | }) | |||
| 190 | + | require.NoError(t, err, i) | |||
| 191 | + | src, err := ci.GetRunSource(ctx, d.Run.ID) | |||
| 192 | + | require.NoError(t, err) | |||
| 193 | + | assert.Equal(t, p, src.Pusher) | |||
| 194 | + | } | |||
| 195 | + | } | |||
M internal/adapters/web/web.go +1 -1
| @@ -235,7 +235,7 @@ func (s *Server) ownerRedirect(r *http.Request) (string, bool) { | |||
| 235 | 235 | return "", false | |
| 236 | 236 | } | |
| 237 | 237 | first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/") | |
| 238 | - | if !ok || first != s.cfg.Owner || rest == "" { | |
| 238 | + | if !ok || !domain.OwnerMatches(s.cfg.Owner, first) || rest == "" { | |
| 239 | 239 | return "", false | |
| 240 | 240 | } | |
| 241 | 241 | repo, _, _ := strings.Cut(rest, "/") | |
| @@ -235,7 +235,7 @@ func (s *Server) ownerRedirect(r *http.Request) (string, bool) { | |||||
| 235 | return "", false | 235 | return "", false | ||
| 236 | } | 236 | } | ||
| 237 | first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/") | 237 | first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/") | ||
| 238 | - | if !ok || first != s.cfg.Owner || rest == "" { | 238 | + | if !ok || !domain.OwnerMatches(s.cfg.Owner, first) || rest == "" { |
| 239 | return "", false | 239 | return "", false | ||
| 240 | } | 240 | } | ||
| 241 | repo, _, _ := strings.Cut(rest, "/") | 241 | repo, _, _ := strings.Cut(rest, "/") | ||
M internal/app/ci_execution_test.go +523 -4
| @@ -11,10 +11,12 @@ import ( | |||
| 11 | 11 | "encoding/json" | |
| 12 | 12 | "io" | |
| 13 | 13 | "net/http" | |
| 14 | + | "net/http/httptest" | |
| 14 | 15 | "path/filepath" | |
| 15 | 16 | "slices" | |
| 16 | 17 | "strings" | |
| 17 | 18 | "sync" | |
| 19 | + | "sync/atomic" | |
| 18 | 20 | "testing" | |
| 19 | 21 | "time" | |
| 20 | 22 | ||
| @@ -655,7 +657,7 @@ func TestResolveActionsAuthentication(t *testing.T) { | |||
| 655 | 657 | } | |
| 656 | 658 | ||
| 657 | 659 | // AC4: an action of a repository of ermite wins over github.com, is downloaded | |
| 658 | - | // with a per-job read token for that repository only, and the same ref gives the | |
| 660 | + | // with a per-job token bound to that commit of that repository, and the same ref gives the | |
| 659 | 661 | // same commit to every job of the attempt (and a new one to a re-run). | |
| 660 | 662 | func TestResolveActionsFromErmite(t *testing.T) { | |
| 661 | 663 | remote := newFakeRemote(t) | |
| @@ -685,7 +687,7 @@ func TestResolveActionsFromErmite(t *testing.T) { | |||
| 685 | 687 | } | |
| 686 | 688 | assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner()) | |
| 687 | 689 | assert.Equal(t, "sub", got[1].GetPath()) | |
| 688 | - | assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per repository and job") | |
| 690 | + | assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per commit and job") | |
| 689 | 691 | assert.Contains(t, got[4].GetError(), "unable to find version `gone`") | |
| 690 | 692 | assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all") | |
| 691 | 693 | assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`") | |
| @@ -698,9 +700,9 @@ func TestResolveActionsFromErmite(t *testing.T) { | |||
| 698 | 700 | require.Equal(t, http.StatusOK, status) | |
| 699 | 701 | assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"]) | |
| 700 | 702 | status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch)) | |
| 701 | - | assert.Equal(t, http.StatusOK, status, "and clones it") | |
| 703 | + | assert.Equal(t, http.StatusNotFound, status, "it does not clone: the archive of one commit is all it gets") | |
| 702 | 704 | status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch)) | |
| 703 | - | assert.Equal(t, http.StatusForbidden, status, "never writes") | |
| 705 | + | assert.Equal(t, http.StatusNotFound, status, "and never writes") | |
| 704 | 706 | status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch)) | |
| 705 | 707 | assert.Equal(t, http.StatusNotFound, status, "another private repository") | |
| 706 | 708 | status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch)) | |
| @@ -790,3 +792,520 @@ func TestRepositoryTokensAreNeverLogged(t *testing.T) { | |||
| 790 | 792 | } | |
| 791 | 793 | assert.NotContains(t, logs, strings.Repeat("Z", 43)) | |
| 792 | 794 | } | |
| 795 | + | ||
| 796 | + | // pushAs pushes a commit to branch of the repository with the token and returns | |
| 797 | + | // its sha. | |
| 798 | + | func (c *ciHarness) pushAs(token, branch string, files map[string]string) string { | |
| 799 | + | c.t.Helper() | |
| 800 | + | if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil { | |
| 801 | + | c.mustGit(c.wt, "checkout", "-q", "-b", branch) | |
| 802 | + | } | |
| 803 | + | for name, content := range files { | |
| 804 | + | c.writeFile(c.wt, name, []byte(content)) | |
| 805 | + | } | |
| 806 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(token, "/"+c.repo+".git"), branch) | |
| 807 | + | return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | const oneJobWorkflow = ".github/workflows/ci.yml" | |
| 811 | + | ||
| 812 | + | func oneJobYAML() string { return strings.Split(ciWorkflow, " test:")[0] } | |
| 813 | + | ||
| 814 | + | // GO-001a: an action of another repository of ermite is resolved on behalf of | |
| 815 | + | // the pusher of the run, with the rule of the forge: a pusher that cannot read | |
| 816 | + | // the repository gets "repository not found", exactly as for one that does not | |
| 817 | + | // exist, and the check is made again at each answer with the live token. | |
| 818 | + | func TestResolveActionsOfAnotherRepositoryNeedsAPusherWhoReadsIt(t *testing.T) { | |
| 819 | + | notFound := "Unable to resolve action `owner/private-action@v1`, repository not found" | |
| 820 | + | missing := "Unable to resolve action `owner/does-not-exist@v1`, repository not found" | |
| 821 | + | ||
| 822 | + | type scenario struct { | |
| 823 | + | name string | |
| 824 | + | // token makes the stored token that pushes the run, nil for the root token. | |
| 825 | + | token func(c *ciHarness) (secret, id string) | |
| 826 | + | reads bool | |
| 827 | + | } | |
| 828 | + | scenarios := []scenario{ | |
| 829 | + | {"root", nil, true}, | |
| 830 | + | {"a token without the repository in its list", func(c *ciHarness) (string, string) { | |
| 831 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 832 | + | }, false}, | |
| 833 | + | {"a token without repo:read", func(c *ciHarness) (string, string) { | |
| 834 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoWrite}, nil) | |
| 835 | + | }, false}, | |
| 836 | + | {"a token that lists both repositories", func(c *ciHarness) (string, string) { | |
| 837 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "private-action") | |
| 838 | + | }, true}, | |
| 839 | + | {"a token for every repository", func(c *ciHarness) (string, string) { | |
| 840 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |
| 841 | + | }, true}, | |
| 842 | + | } | |
| 843 | + | for _, sc := range scenarios { | |
| 844 | + | t.Run(sc.name, func(t *testing.T) { | |
| 845 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 846 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |
| 847 | + | secret := adminToken | |
| 848 | + | if sc.token != nil { | |
| 849 | + | secret, _ = sc.token(c) | |
| 850 | + | } | |
| 851 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 852 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 853 | + | job := runner.await() | |
| 854 | + | c.heartbeat(runner, job) | |
| 855 | + | ||
| 856 | + | got := c.resolve(runner, job, "owner/private-action@v1", "owner/does-not-exist@v1") | |
| 857 | + | assert.Equal(t, missing, got[1].GetError()) | |
| 858 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 859 | + | got = c.resolve(runner, job, "owner/private-action@v1") | |
| 860 | + | if sc.reads { | |
| 861 | + | require.Empty(t, got[0].GetError()) | |
| 862 | + | assert.True(t, domain.IsRepoToken(got[0].GetToken())) | |
| 863 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource()) | |
| 864 | + | } else { | |
| 865 | + | assert.Equal(t, notFound, got[0].GetError(), "as GitHub says for a repository that is not there") | |
| 866 | + | assert.Empty(t, got[0].GetToken()) | |
| 867 | + | assert.Empty(t, got[0].GetResolvedSha()) | |
| 868 | + | assert.Empty(t, got[0].GetTarballUrl()) | |
| 869 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, got[0].GetSource()) | |
| 870 | + | } | |
| 871 | + | // A wrong ref of a repository that cannot be read leaks nothing either: | |
| 872 | + | // the same text as for a missing repository, not "unable to find version". | |
| 873 | + | bad := c.resolve(runner, job, "owner/private-action@nope") | |
| 874 | + | if sc.reads { | |
| 875 | + | assert.Contains(t, bad[0].GetError(), "unable to find version `nope`") | |
| 876 | + | } else { | |
| 877 | + | assert.Equal(t, "Unable to resolve action `owner/private-action@nope`, repository not found", bad[0].GetError()) | |
| 878 | + | } | |
| 879 | + | }) | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | t.Run("the token is looked up again, so revoking it stops the answers", func(t *testing.T) { | |
| 883 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 884 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |
| 885 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 886 | + | secret, id := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |
| 887 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 888 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 889 | + | job := runner.await() | |
| 890 | + | c.heartbeat(runner, job) | |
| 891 | + | ||
| 892 | + | first := c.resolve(runner, job, "owner/private-action@v1") | |
| 893 | + | require.Empty(t, first[0].GetError()) | |
| 894 | + | ||
| 895 | + | require.NoError(t, c.forge.Tokens.Revoke(c.ctx, domain.Root(), id)) | |
| 896 | + | again := c.resolve(runner, job, "owner/private-action@v1") | |
| 897 | + | assert.Equal(t, notFound, again[0].GetError(), "the commit is resolved already, the pusher no longer reads") | |
| 898 | + | assert.Empty(t, again[0].GetToken()) | |
| 899 | + | }) | |
| 900 | + | ||
| 901 | + | t.Run("a repository made private since", func(t *testing.T) { | |
| 902 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 903 | + | c.seed("private-action", domain.VisibilityPublic, "action.yml") | |
| 904 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 905 | + | secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 906 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 907 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 908 | + | job := runner.await() | |
| 909 | + | c.heartbeat(runner, job) | |
| 910 | + | ||
| 911 | + | assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError(), "public: anyone reads it") | |
| 912 | + | priv := domain.VisibilityPrivate | |
| 913 | + | _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("private-action"), ports.UpdateRepoParams{Visibility: &priv}) | |
| 914 | + | require.NoError(t, err) | |
| 915 | + | assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |
| 916 | + | }) | |
| 917 | + | ||
| 918 | + | t.Run("an expired token", func(t *testing.T) { | |
| 919 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 920 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |
| 921 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |
| 922 | + | exp := time.Now().Add(1500 * time.Millisecond) | |
| 923 | + | got, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{ | |
| 924 | + | Label: "short", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, ExpiresAt: exp, | |
| 925 | + | }) | |
| 926 | + | require.NoError(t, err) | |
| 927 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 928 | + | c.pushAs(got.Secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 929 | + | job := runner.await() | |
| 930 | + | c.heartbeat(runner, job) | |
| 931 | + | assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |
| 932 | + | time.Sleep(time.Until(exp) + 200*time.Millisecond) | |
| 933 | + | assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |
| 934 | + | }) | |
| 935 | + | ||
| 936 | + | t.Run("the repository of the run is always readable by its job", func(t *testing.T) { | |
| 937 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 938 | + | secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |
| 939 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 940 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |
| 941 | + | job := runner.await() | |
| 942 | + | c.heartbeat(runner, job) | |
| 943 | + | got := c.resolve(runner, job, "owner/proj@main") | |
| 944 | + | require.Empty(t, got[0].GetError()) | |
| 945 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource()) | |
| 946 | + | }) | |
| 947 | + | ||
| 948 | + | t.Run("a merge made by the owner's session", func(t *testing.T) { | |
| 949 | + | // The runs of merged requests record the owner: a principal that reads | |
| 950 | + | // everything. | |
| 951 | + | p := domain.PusherOf(domain.Owner()) | |
| 952 | + | assert.Equal(t, domain.CIPusherOwner, p.Kind) | |
| 953 | + | }) | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | // GO-001b: the archive token is bound to (repository, commit). Only the archive | |
| 957 | + | // endpoint reads it, only for that commit; it is refused by Git over HTTP, the | |
| 958 | + | // repository and hash-algorithm endpoints, and for any other commit, ref or | |
| 959 | + | // repository, public or not. | |
| 960 | + | func TestArchiveTokenIsBoundToItsCommit(t *testing.T) { | |
| 961 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 962 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js") | |
| 963 | + | first := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 964 | + | c.mustGit(c.repoDir("myaction"), "tag", "v1") | |
| 965 | + | // A second commit, to have a sha the token was not issued for. | |
| 966 | + | other := c.workTree("main", "action.yml", "dist/index.js", "more.js") | |
| 967 | + | c.mustGit(other, "push", "-q", "-f", c.urlAuth(adminToken, "/myaction.git"), "main") | |
| 968 | + | second := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 969 | + | require.NotEqual(t, first, second) | |
| 970 | + | c.seed("openrepo", domain.VisibilityPublic, "a.txt") | |
| 971 | + | openSha := c.bareGit("openrepo", "rev-parse", "HEAD") | |
| 972 | + | ||
| 973 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 974 | + | job := c.startJob(runner) | |
| 975 | + | c.heartbeat(runner, job) | |
| 976 | + | got := c.resolve(runner, job, "owner/myaction@v1", "owner/myaction@"+second) | |
| 977 | + | require.Empty(t, got[0].GetError()) | |
| 978 | + | require.Empty(t, got[1].GetError()) | |
| 979 | + | assert.Equal(t, first, got[0].GetResolvedSha()) | |
| 980 | + | assert.Equal(t, second, got[1].GetResolvedSha()) | |
| 981 | + | assert.NotEqual(t, got[0].GetToken(), got[1].GetToken(), "one token per commit") | |
| 982 | + | arch := got[0].GetToken() | |
| 983 | + | tarball := func(repo, ref, tok string) int { | |
| 984 | + | status, _, _ := c.get("/-/github/api/repos/owner/"+repo+"/tarball/"+ref, basic(tok)) | |
| 985 | + | return status | |
| 986 | + | } | |
| 987 | + | ||
| 988 | + | // What it is for: that commit, named by its sha or by a ref that resolves to it. | |
| 989 | + | assert.Equal(t, http.StatusOK, tarball("myaction", first, arch)) | |
| 990 | + | assert.Equal(t, http.StatusOK, tarball("myaction", "v1", arch), "any ref that resolves to the commit") | |
| 991 | + | assert.Equal(t, http.StatusOK, tarball("MyAction", first, arch), "names are case-insensitive") | |
| 992 | + | status, _, hdr := c.head("/-/github/api/repos/owner/myaction/tarball/"+first, basic(arch)) | |
| 993 | + | assert.Equal(t, http.StatusOK, status) | |
| 994 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-myaction-"+first[:7]) | |
| 995 | + | ||
| 996 | + | // Nothing else. | |
| 997 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", second, arch), "another commit of the repository") | |
| 998 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", "main", arch), "a ref that now points elsewhere") | |
| 999 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", "", arch), "the default branch") | |
| 1000 | + | assert.Equal(t, http.StatusNotFound, tarball("proj", "main", arch), "the repository of the run") | |
| 1001 | + | assert.Equal(t, http.StatusNotFound, tarball("openrepo", openSha, arch), "not even a public repository") | |
| 1002 | + | assert.Equal(t, http.StatusUnauthorized, tarball("myaction", first, arch+"x"), "a token that is not one") | |
| 1003 | + | status, _, _ = c.head("/-/github/api/repos/owner/myaction/tarball/"+second, basic(arch)) | |
| 1004 | + | assert.Equal(t, http.StatusNotFound, status, "HEAD is checked like GET") | |
| 1005 | + | for _, p := range []string{ | |
| 1006 | + | "/owner/myaction/info/refs?service=git-upload-pack", | |
| 1007 | + | "/owner/myaction.git/info/refs?service=git-upload-pack", | |
| 1008 | + | "/myaction/info/refs?service=git-upload-pack", | |
| 1009 | + | "/owner/myaction/info/refs?service=git-receive-pack", | |
| 1010 | + | "/owner/openrepo/info/refs?service=git-upload-pack", | |
| 1011 | + | "/-/github/api/repos/owner/myaction", | |
| 1012 | + | "/-/github/api/repos/owner/myaction/hash-algorithm", | |
| 1013 | + | "/-/github/api/repos/owner/openrepo", | |
| 1014 | + | "/-/github/api/repos/owner/openrepo/hash-algorithm", | |
| 1015 | + | } { | |
| 1016 | + | status, _, _ := c.get(p, basic(arch)) | |
| 1017 | + | assert.Contains(t, []int{http.StatusNotFound, http.StatusUnauthorized}, status, p) | |
| 1018 | + | } | |
| 1019 | + | // It lists no repository over the API either, and holds no scope there. | |
| 1020 | + | list, err := c.api(arch).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{})) | |
| 1021 | + | require.NoError(t, err) | |
| 1022 | + | names := []string{} | |
| 1023 | + | for _, r := range list.Msg.GetRepos() { | |
| 1024 | + | names = append(names, r.GetName()) | |
| 1025 | + | } | |
| 1026 | + | assert.Empty(t, names, "it reads nothing, not even the public repositories an anonymous caller reads") | |
| 1027 | + | _, err = c.api(arch).repo.GetRepo(c.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "myaction"})) | |
| 1028 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 1029 | + | ||
| 1030 | + | // The job's own token is the opposite: it reads its repository whole, and | |
| 1031 | + | // the archive of nothing else. | |
| 1032 | + | own := job.GetGithubToken() | |
| 1033 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", first, own)) | |
| 1034 | + | assert.Equal(t, http.StatusOK, tarball("proj", "main", own)) | |
| 1035 | + | ||
| 1036 | + | // Principals. | |
| 1037 | + | p, err := c.forge.Auth.Authenticate(c.ctx, arch) | |
| 1038 | + | require.NoError(t, err) | |
| 1039 | + | repo, sha, ok := p.ArchiveGrant() | |
| 1040 | + | require.True(t, ok) | |
| 1041 | + | assert.Equal(t, "myaction", repo.String()) | |
| 1042 | + | assert.Equal(t, first, sha) | |
| 1043 | + | assert.Empty(t, p.Scopes()) | |
| 1044 | + | assert.False(t, p.HasScope(domain.ScopeRepoRead)) | |
| 1045 | + | assert.False(t, p.Allows(domain.ScopeRepoRead, repo)) | |
| 1046 | + | assert.False(t, p.IsRoot()) | |
| 1047 | + | ownP, err := c.forge.Auth.Authenticate(c.ctx, own) | |
| 1048 | + | require.NoError(t, err) | |
| 1049 | + | _, _, ok = ownP.ArchiveGrant() | |
| 1050 | + | assert.False(t, ok) | |
| 1051 | + | } | |
| 1052 | + | ||
| 1053 | + | func (c *ciHarness) head(path, authorization string) (int, string, http.Header) { | |
| 1054 | + | c.t.Helper() | |
| 1055 | + | req, err := http.NewRequest(http.MethodHead, c.url(path), nil) | |
| 1056 | + | require.NoError(c.t, err) | |
| 1057 | + | if authorization != "" { | |
| 1058 | + | req.Header.Set("Authorization", authorization) | |
| 1059 | + | } | |
| 1060 | + | resp, err := http.DefaultClient.Do(req) | |
| 1061 | + | require.NoError(c.t, err) | |
| 1062 | + | defer func() { _ = resp.Body.Close() }() | |
| 1063 | + | b, _ := io.ReadAll(resp.Body) | |
| 1064 | + | return resp.StatusCode, string(b), resp.Header | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | // GO-002: a HEAD of the tarball answers the headers of the archive of the ref it | |
| 1068 | + | // resolves, runs no git archive, and 404s a ref that does not resolve. | |
| 1069 | + | func TestTarballHeadRunsNoArchive(t *testing.T) { | |
| 1070 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 1071 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1072 | + | job := c.startJob(runner) | |
| 1073 | + | tok := job.GetGithubToken() | |
| 1074 | + | sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 1075 | + | ||
| 1076 | + | status, body, hdr := c.head("/-/github/api/repos/owner/proj/tarball/main", basic(tok)) | |
| 1077 | + | assert.Equal(t, http.StatusOK, status) | |
| 1078 | + | assert.Empty(t, body, "no body") | |
| 1079 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz") | |
| 1080 | + | assert.Contains(t, hdr.Get("Content-Type"), "gzip") | |
| 1081 | + | status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/nope", basic(tok)) | |
| 1082 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 1083 | + | status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/main", "") | |
| 1084 | + | assert.Equal(t, http.StatusNotFound, status, "private and anonymous") | |
| 1085 | + | status, _, _ = c.head("/-/github/api/repos/owner/other/tarball/main", basic(tok)) | |
| 1086 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 1087 | + | } | |
| 1088 | + | ||
| 1089 | + | // GO-003: one client cannot take every archive slot, and the slots are given | |
| 1090 | + | // back. | |
| 1091 | + | func TestArchiveDownloadsAreLimitedPerClient(t *testing.T) { | |
| 1092 | + | h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.archivesPerClient = 1 }) | |
| 1093 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |
| 1094 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |
| 1095 | + | require.NoError(t, err) | |
| 1096 | + | c.wt = h.workTree("main") | |
| 1097 | + | c.writeFile(c.wt, "big.bin", bytes.Repeat([]byte("0123456789abcdef"), 1<<20)) // 16 MiB, compressible | |
| 1098 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main") | |
| 1099 | + | ||
| 1100 | + | // Start a download and read none of it: the response is held open. | |
| 1101 | + | req, err := http.NewRequest(http.MethodGet, c.url("/-/github/api/repos/owner/proj/tarball/main"), nil) | |
| 1102 | + | require.NoError(t, err) | |
| 1103 | + | req.Header.Set("Authorization", "Bearer "+adminToken) | |
| 1104 | + | resp, err := http.DefaultClient.Do(req) | |
| 1105 | + | require.NoError(t, err) | |
| 1106 | + | ||
| 1107 | + | var status int | |
| 1108 | + | c.waitFor("the second download is refused", func() bool { | |
| 1109 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |
| 1110 | + | return status == http.StatusTooManyRequests | |
| 1111 | + | }) | |
| 1112 | + | assert.Equal(t, http.StatusTooManyRequests, status) | |
| 1113 | + | hs, _, _ := c.head("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |
| 1114 | + | assert.Equal(t, http.StatusOK, hs, "a HEAD takes no slot") | |
| 1115 | + | metaStatus, _, _ := c.get("/-/github/api/repos/owner/proj", "Bearer "+adminToken) | |
| 1116 | + | assert.Equal(t, http.StatusOK, metaStatus, "nor does the repository endpoint") | |
| 1117 | + | ||
| 1118 | + | // Reading it to the end gives the slot back. | |
| 1119 | + | _, err = io.Copy(io.Discard, resp.Body) | |
| 1120 | + | require.NoError(t, err) | |
| 1121 | + | _ = resp.Body.Close() | |
| 1122 | + | c.waitFor("the slot is given back", func() bool { | |
| 1123 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |
| 1124 | + | return status == http.StatusOK | |
| 1125 | + | }) | |
| 1126 | + | } | |
| 1127 | + | ||
| 1128 | + | // GO-004: owner and repository are validated with GitHub's charset for every | |
| 1129 | + | // source; nothing but a name reaches a URL, a path or a command. | |
| 1130 | + | func TestResolveActionsRejectsMalformedNames(t *testing.T) { | |
| 1131 | + | remote := newFakeRemote(t) | |
| 1132 | + | c := newExecHarness(t, remote, nil) | |
| 1133 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1134 | + | job := c.startJob(runner) | |
| 1135 | + | c.heartbeat(runner, job) | |
| 1136 | + | ||
| 1137 | + | bad := []string{ | |
| 1138 | + | "../..@" + shaMain, ".../x@" + shaMain, "../x@" + shaMain, "..//x@" + shaMain, "a/..@" + shaMain, "../b@" + shaMain, | |
| 1139 | + | "a/b%2e@" + shaMain, "a%2fb/c@" + shaMain, "a/b?x=1@" + shaMain, "a/b#f@" + shaMain, "a b/c@" + shaMain, | |
| 1140 | + | "a/b c@" + shaMain, "a:b/c@" + shaMain, "a/b\n@" + shaMain, "a@b/c@v1", "a/b\x00@v1", "a/...@v1", "a/.@v1", | |
| 1141 | + | "é/b@v1", strings.Repeat("a", 101) + "/b@v1", "a/" + strings.Repeat("b", 101) + "@v1", "/@v1", "@@", | |
| 1142 | + | } | |
| 1143 | + | got := c.resolve(runner, job, bad...) | |
| 1144 | + | require.Len(t, got, len(bad)) | |
| 1145 | + | for i, g := range got { | |
| 1146 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual '"+bad[i]+"'", g.GetError(), "%q", bad[i]) | |
| 1147 | + | assert.Empty(t, g.GetTarballUrl(), "%q", bad[i]) | |
| 1148 | + | assert.Empty(t, g.GetToken(), "%q", bad[i]) | |
| 1149 | + | } | |
| 1150 | + | assert.Zero(t, remote.count(), "no malformed name reached ls-remote") | |
| 1151 | + | ||
| 1152 | + | // Names GitHub accepts still do. | |
| 1153 | + | remote.set("a.b/c_d-e", ptrRef("refs/tags/v1", shaMain)) | |
| 1154 | + | ok := c.resolve(runner, job, "a.b/c_d-e@v1", "Owner/repo/sub@"+shaMain) | |
| 1155 | + | assert.Empty(t, ok[0].GetError()) | |
| 1156 | + | assert.Empty(t, ok[1].GetError()) | |
| 1157 | + | } | |
| 1158 | + | ||
| 1159 | + | // A token revoked, a job lost, the server restarted: the archive token goes with | |
| 1160 | + | // the job, and it was never written anywhere but hashed. | |
| 1161 | + | func TestArchiveTokenIsRevokedWithTheJobAfterARestart(t *testing.T) { | |
| 1162 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |
| 1163 | + | cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 600*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 1164 | + | }) | |
| 1165 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |
| 1166 | + | sha := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 1167 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1168 | + | job := c.startJob(runner) | |
| 1169 | + | c.heartbeat(runner, job) | |
| 1170 | + | arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken() | |
| 1171 | + | require.NotEmpty(t, arch) | |
| 1172 | + | url := "/-/github/api/repos/owner/myaction/tarball/" + sha | |
| 1173 | + | status, _, _ := c.get(url, basic(arch)) | |
| 1174 | + | require.Equal(t, http.StatusOK, status) | |
| 1175 | + | ||
| 1176 | + | // Restart: the tokens are in the store, the secrets in memory are gone. | |
| 1177 | + | cfg, hookExe := c.forge.cfg, binary(t) | |
| 1178 | + | c.srv.Close() | |
| 1179 | + | c.forge.Close(c.ctx) | |
| 1180 | + | f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, remoteLister: newFakeRemote(t).m, ci: func(cc *usecase.CIConfig) { | |
| 1181 | + | cc.HeartbeatDeadline, cc.Tick = 600*time.Millisecond, 20*time.Millisecond | |
| 1182 | + | }}) | |
| 1183 | + | require.NoError(t, err) | |
| 1184 | + | t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) }) | |
| 1185 | + | srv := httptest.NewServer(f2.server.Handler()) | |
| 1186 | + | t.Cleanup(srv.Close) | |
| 1187 | + | ||
| 1188 | + | get := func(tok string) int { | |
| 1189 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+url, nil) | |
| 1190 | + | req.Header.Set("Authorization", basic(tok)) | |
| 1191 | + | resp, err := http.DefaultClient.Do(req) | |
| 1192 | + | require.NoError(t, err) | |
| 1193 | + | _ = resp.Body.Close() | |
| 1194 | + | return resp.StatusCode | |
| 1195 | + | } | |
| 1196 | + | assert.Equal(t, http.StatusOK, get(arch), "the token survives a restart") | |
| 1197 | + | ||
| 1198 | + | // The runner is gone: the heartbeat deadline of the new server passes, the | |
| 1199 | + | // job is lost and its tokens with it. | |
| 1200 | + | require.Eventually(t, func() bool { return get(arch) == http.StatusUnauthorized }, 10*time.Second, 50*time.Millisecond) | |
| 1201 | + | assert.Equal(t, http.StatusUnauthorized, get(job.GetGithubToken()), "the job's own token as well") | |
| 1202 | + | api := newClients(srv.URL+"/-/api", adminToken) | |
| 1203 | + | res, err := api.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: job.GetRunId()})) | |
| 1204 | + | require.NoError(t, err) | |
| 1205 | + | assert.Contains(t, res.Msg.GetJobs()[0].GetError(), "lost communication") | |
| 1206 | + | } | |
| 1207 | + | ||
| 1208 | + | // GO-006: the archive tokens that read a repository go when it is deleted, and | |
| 1209 | + | // the run that held one goes on without a stale index. | |
| 1210 | + | func TestArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) { | |
| 1211 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 1212 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |
| 1213 | + | sha := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 1214 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1215 | + | job := c.startJob(runner) | |
| 1216 | + | c.heartbeat(runner, job) | |
| 1217 | + | arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken() | |
| 1218 | + | _, err := c.forge.Auth.Authenticate(c.ctx, arch) | |
| 1219 | + | require.NoError(t, err) | |
| 1220 | + | ||
| 1221 | + | require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("myaction"))) | |
| 1222 | + | _, err = c.forge.Auth.Authenticate(c.ctx, arch) | |
| 1223 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "deleted with the repository it reads") | |
| 1224 | + | _, err = c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken()) | |
| 1225 | + | assert.NoError(t, err, "the job's own token is the run's repository's, not touched") | |
| 1226 | + | ||
| 1227 | + | // A repository created again under the name does not inherit the token. | |
| 1228 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |
| 1229 | + | _, err = c.forge.Auth.Authenticate(c.ctx, arch) | |
| 1230 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 1231 | + | status, _, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+sha, basic(arch)) | |
| 1232 | + | assert.Equal(t, http.StatusUnauthorized, status) | |
| 1233 | + | ||
| 1234 | + | // The job ending afterwards revokes what is left without an error. | |
| 1235 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 1236 | + | } | |
| 1237 | + | ||
| 1238 | + | // GO-005: ls-remote runs are bounded server-wide and a call has a deadline. | |
| 1239 | + | func TestResolveActionsBoundsRemoteWork(t *testing.T) { | |
| 1240 | + | t.Run("concurrency", func(t *testing.T) { | |
| 1241 | + | // The jobs of a run resolve at the same time: five calls, two slots. | |
| 1242 | + | var running, peak atomic.Int32 | |
| 1243 | + | release := make(chan struct{}) | |
| 1244 | + | remote := newFakeRemote(t) | |
| 1245 | + | remote.m.ExpectedCalls = nil | |
| 1246 | + | remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 1247 | + | func(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| 1248 | + | n := running.Add(1) | |
| 1249 | + | defer running.Add(-1) | |
| 1250 | + | for { | |
| 1251 | + | p := peak.Load() | |
| 1252 | + | if n <= p || peak.CompareAndSwap(p, n) { | |
| 1253 | + | break | |
| 1254 | + | } | |
| 1255 | + | } | |
| 1256 | + | select { | |
| 1257 | + | case <-release: | |
| 1258 | + | case <-ctx.Done(): | |
| 1259 | + | return nil, ctx.Err() | |
| 1260 | + | } | |
| 1261 | + | return []ports.RemoteRef{{Name: "refs/tags/v1", ID: shaMain}}, nil | |
| 1262 | + | }).Maybe() | |
| 1263 | + | c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.RemoteConcurrency = 2 }) | |
| 1264 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1265 | + | job := c.startJob(runner) | |
| 1266 | + | c.heartbeat(runner, job) | |
| 1267 | + | ||
| 1268 | + | var wg sync.WaitGroup | |
| 1269 | + | results := make([][]*pb.ResolvedAction, 5) | |
| 1270 | + | errs := make([]error, 5) | |
| 1271 | + | for i, repo := range []string{"one", "two", "three", "four", "five"} { | |
| 1272 | + | wg.Add(1) | |
| 1273 | + | go func() { | |
| 1274 | + | defer wg.Done() | |
| 1275 | + | results[i], errs[i] = c.resolveErr(runner, job, "a/"+repo+"@v1") | |
| 1276 | + | }() | |
| 1277 | + | } | |
| 1278 | + | require.Eventually(t, func() bool { return running.Load() == 2 }, 5*time.Second, 10*time.Millisecond) | |
| 1279 | + | time.Sleep(150 * time.Millisecond) | |
| 1280 | + | assert.EqualValues(t, 2, running.Load(), "the others wait for a slot") | |
| 1281 | + | close(release) | |
| 1282 | + | wg.Wait() | |
| 1283 | + | assert.LessOrEqual(t, peak.Load(), int32(2)) | |
| 1284 | + | for i := range results { | |
| 1285 | + | require.NoError(t, errs[i]) | |
| 1286 | + | assert.Empty(t, results[i][0].GetError(), results[i][0].GetRef()) | |
| 1287 | + | } | |
| 1288 | + | }) | |
| 1289 | + | ||
| 1290 | + | t.Run("deadline", func(t *testing.T) { | |
| 1291 | + | remote := newFakeRemote(t) | |
| 1292 | + | remote.m.ExpectedCalls = nil | |
| 1293 | + | remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 1294 | + | func(ctx context.Context, _, _ string) ([]ports.RemoteRef, error) { | |
| 1295 | + | <-ctx.Done() | |
| 1296 | + | return nil, ctx.Err() | |
| 1297 | + | }).Maybe() | |
| 1298 | + | c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.ResolveTimeout = 300 * time.Millisecond }) | |
| 1299 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 1300 | + | job := c.startJob(runner) | |
| 1301 | + | c.heartbeat(runner, job) | |
| 1302 | + | start := time.Now() | |
| 1303 | + | _, err := c.resolveErr(runner, job, "slow/action@v1") | |
| 1304 | + | assert.Equal(t, connect.CodeUnavailable, ciErrCode(err), "a deadline of ours is unavailable, not deadline_exceeded") | |
| 1305 | + | assert.Less(t, time.Since(start), 5*time.Second) | |
| 1306 | + | // The job goes on: the next call is answered. | |
| 1307 | + | ok, err := c.resolveErr(runner, job, "a/b@"+shaMain) | |
| 1308 | + | require.NoError(t, err) | |
| 1309 | + | assert.Empty(t, ok[0].GetError()) | |
| 1310 | + | }) | |
| 1311 | + | } | |
| @@ -11,10 +11,12 @@ import ( | |||||
| 11 | "encoding/json" | 11 | "encoding/json" | ||
| 12 | "io" | 12 | "io" | ||
| 13 | "net/http" | 13 | "net/http" | ||
| 14 | + | "net/http/httptest" | |||
| 14 | "path/filepath" | 15 | "path/filepath" | ||
| 15 | "slices" | 16 | "slices" | ||
| 16 | "strings" | 17 | "strings" | ||
| 17 | "sync" | 18 | "sync" | ||
| 19 | + | "sync/atomic" | |||
| 18 | "testing" | 20 | "testing" | ||
| 19 | "time" | 21 | "time" | ||
| 20 | 22 | ||||
| @@ -655,7 +657,7 @@ func TestResolveActionsAuthentication(t *testing.T) { | |||||
| 655 | } | 657 | } | ||
| 656 | 658 | ||||
| 657 | // AC4: an action of a repository of ermite wins over github.com, is downloaded | 659 | // AC4: an action of a repository of ermite wins over github.com, is downloaded | ||
| 658 | - | // with a per-job read token for that repository only, and the same ref gives the | 660 | + | // with a per-job token bound to that commit of that repository, and the same ref gives the |
| 659 | // same commit to every job of the attempt (and a new one to a re-run). | 661 | // same commit to every job of the attempt (and a new one to a re-run). | ||
| 660 | func TestResolveActionsFromErmite(t *testing.T) { | 662 | func TestResolveActionsFromErmite(t *testing.T) { | ||
| 661 | remote := newFakeRemote(t) | 663 | remote := newFakeRemote(t) | ||
| @@ -685,7 +687,7 @@ func TestResolveActionsFromErmite(t *testing.T) { | |||||
| 685 | } | 687 | } | ||
| 686 | assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner()) | 688 | assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner()) | ||
| 687 | assert.Equal(t, "sub", got[1].GetPath()) | 689 | assert.Equal(t, "sub", got[1].GetPath()) | ||
| 688 | - | assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per repository and job") | 690 | + | assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per commit and job") |
| 689 | assert.Contains(t, got[4].GetError(), "unable to find version `gone`") | 691 | assert.Contains(t, got[4].GetError(), "unable to find version `gone`") | ||
| 690 | assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all") | 692 | assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all") | ||
| 691 | assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`") | 693 | assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`") | ||
| @@ -698,9 +700,9 @@ func TestResolveActionsFromErmite(t *testing.T) { | |||||
| 698 | require.Equal(t, http.StatusOK, status) | 700 | require.Equal(t, http.StatusOK, status) | ||
| 699 | assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"]) | 701 | assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"]) | ||
| 700 | status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch)) | 702 | status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch)) | ||
| 701 | - | assert.Equal(t, http.StatusOK, status, "and clones it") | 703 | + | assert.Equal(t, http.StatusNotFound, status, "it does not clone: the archive of one commit is all it gets") |
| 702 | status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch)) | 704 | status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch)) | ||
| 703 | - | assert.Equal(t, http.StatusForbidden, status, "never writes") | 705 | + | assert.Equal(t, http.StatusNotFound, status, "and never writes") |
| 704 | status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch)) | 706 | status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch)) | ||
| 705 | assert.Equal(t, http.StatusNotFound, status, "another private repository") | 707 | assert.Equal(t, http.StatusNotFound, status, "another private repository") | ||
| 706 | status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch)) | 708 | status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch)) | ||
| @@ -790,3 +792,520 @@ func TestRepositoryTokensAreNeverLogged(t *testing.T) { | |||||
| 790 | } | 792 | } | ||
| 791 | assert.NotContains(t, logs, strings.Repeat("Z", 43)) | 793 | assert.NotContains(t, logs, strings.Repeat("Z", 43)) | ||
| 792 | } | 794 | } | ||
| 795 | + | ||||
| 796 | + | // pushAs pushes a commit to branch of the repository with the token and returns | |||
| 797 | + | // its sha. | |||
| 798 | + | func (c *ciHarness) pushAs(token, branch string, files map[string]string) string { | |||
| 799 | + | c.t.Helper() | |||
| 800 | + | if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil { | |||
| 801 | + | c.mustGit(c.wt, "checkout", "-q", "-b", branch) | |||
| 802 | + | } | |||
| 803 | + | for name, content := range files { | |||
| 804 | + | c.writeFile(c.wt, name, []byte(content)) | |||
| 805 | + | } | |||
| 806 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(token, "/"+c.repo+".git"), branch) | |||
| 807 | + | return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | const oneJobWorkflow = ".github/workflows/ci.yml" | |||
| 811 | + | ||||
| 812 | + | func oneJobYAML() string { return strings.Split(ciWorkflow, " test:")[0] } | |||
| 813 | + | ||||
| 814 | + | // GO-001a: an action of another repository of ermite is resolved on behalf of | |||
| 815 | + | // the pusher of the run, with the rule of the forge: a pusher that cannot read | |||
| 816 | + | // the repository gets "repository not found", exactly as for one that does not | |||
| 817 | + | // exist, and the check is made again at each answer with the live token. | |||
| 818 | + | func TestResolveActionsOfAnotherRepositoryNeedsAPusherWhoReadsIt(t *testing.T) { | |||
| 819 | + | notFound := "Unable to resolve action `owner/private-action@v1`, repository not found" | |||
| 820 | + | missing := "Unable to resolve action `owner/does-not-exist@v1`, repository not found" | |||
| 821 | + | ||||
| 822 | + | type scenario struct { | |||
| 823 | + | name string | |||
| 824 | + | // token makes the stored token that pushes the run, nil for the root token. | |||
| 825 | + | token func(c *ciHarness) (secret, id string) | |||
| 826 | + | reads bool | |||
| 827 | + | } | |||
| 828 | + | scenarios := []scenario{ | |||
| 829 | + | {"root", nil, true}, | |||
| 830 | + | {"a token without the repository in its list", func(c *ciHarness) (string, string) { | |||
| 831 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |||
| 832 | + | }, false}, | |||
| 833 | + | {"a token without repo:read", func(c *ciHarness) (string, string) { | |||
| 834 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoWrite}, nil) | |||
| 835 | + | }, false}, | |||
| 836 | + | {"a token that lists both repositories", func(c *ciHarness) (string, string) { | |||
| 837 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "private-action") | |||
| 838 | + | }, true}, | |||
| 839 | + | {"a token for every repository", func(c *ciHarness) (string, string) { | |||
| 840 | + | return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |||
| 841 | + | }, true}, | |||
| 842 | + | } | |||
| 843 | + | for _, sc := range scenarios { | |||
| 844 | + | t.Run(sc.name, func(t *testing.T) { | |||
| 845 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 846 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |||
| 847 | + | secret := adminToken | |||
| 848 | + | if sc.token != nil { | |||
| 849 | + | secret, _ = sc.token(c) | |||
| 850 | + | } | |||
| 851 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 852 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |||
| 853 | + | job := runner.await() | |||
| 854 | + | c.heartbeat(runner, job) | |||
| 855 | + | ||||
| 856 | + | got := c.resolve(runner, job, "owner/private-action@v1", "owner/does-not-exist@v1") | |||
| 857 | + | assert.Equal(t, missing, got[1].GetError()) | |||
| 858 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |||
| 859 | + | got = c.resolve(runner, job, "owner/private-action@v1") | |||
| 860 | + | if sc.reads { | |||
| 861 | + | require.Empty(t, got[0].GetError()) | |||
| 862 | + | assert.True(t, domain.IsRepoToken(got[0].GetToken())) | |||
| 863 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource()) | |||
| 864 | + | } else { | |||
| 865 | + | assert.Equal(t, notFound, got[0].GetError(), "as GitHub says for a repository that is not there") | |||
| 866 | + | assert.Empty(t, got[0].GetToken()) | |||
| 867 | + | assert.Empty(t, got[0].GetResolvedSha()) | |||
| 868 | + | assert.Empty(t, got[0].GetTarballUrl()) | |||
| 869 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, got[0].GetSource()) | |||
| 870 | + | } | |||
| 871 | + | // A wrong ref of a repository that cannot be read leaks nothing either: | |||
| 872 | + | // the same text as for a missing repository, not "unable to find version". | |||
| 873 | + | bad := c.resolve(runner, job, "owner/private-action@nope") | |||
| 874 | + | if sc.reads { | |||
| 875 | + | assert.Contains(t, bad[0].GetError(), "unable to find version `nope`") | |||
| 876 | + | } else { | |||
| 877 | + | assert.Equal(t, "Unable to resolve action `owner/private-action@nope`, repository not found", bad[0].GetError()) | |||
| 878 | + | } | |||
| 879 | + | }) | |||
| 880 | + | } | |||
| 881 | + | ||||
| 882 | + | t.Run("the token is looked up again, so revoking it stops the answers", func(t *testing.T) { | |||
| 883 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 884 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |||
| 885 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |||
| 886 | + | secret, id := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil) | |||
| 887 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 888 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |||
| 889 | + | job := runner.await() | |||
| 890 | + | c.heartbeat(runner, job) | |||
| 891 | + | ||||
| 892 | + | first := c.resolve(runner, job, "owner/private-action@v1") | |||
| 893 | + | require.Empty(t, first[0].GetError()) | |||
| 894 | + | ||||
| 895 | + | require.NoError(t, c.forge.Tokens.Revoke(c.ctx, domain.Root(), id)) | |||
| 896 | + | again := c.resolve(runner, job, "owner/private-action@v1") | |||
| 897 | + | assert.Equal(t, notFound, again[0].GetError(), "the commit is resolved already, the pusher no longer reads") | |||
| 898 | + | assert.Empty(t, again[0].GetToken()) | |||
| 899 | + | }) | |||
| 900 | + | ||||
| 901 | + | t.Run("a repository made private since", func(t *testing.T) { | |||
| 902 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 903 | + | c.seed("private-action", domain.VisibilityPublic, "action.yml") | |||
| 904 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |||
| 905 | + | secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |||
| 906 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 907 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |||
| 908 | + | job := runner.await() | |||
| 909 | + | c.heartbeat(runner, job) | |||
| 910 | + | ||||
| 911 | + | assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError(), "public: anyone reads it") | |||
| 912 | + | priv := domain.VisibilityPrivate | |||
| 913 | + | _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("private-action"), ports.UpdateRepoParams{Visibility: &priv}) | |||
| 914 | + | require.NoError(t, err) | |||
| 915 | + | assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |||
| 916 | + | }) | |||
| 917 | + | ||||
| 918 | + | t.Run("an expired token", func(t *testing.T) { | |||
| 919 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 920 | + | c.seed("private-action", domain.VisibilityPrivate, "action.yml") | |||
| 921 | + | c.mustGit(c.repoDir("private-action"), "tag", "v1") | |||
| 922 | + | exp := time.Now().Add(1500 * time.Millisecond) | |||
| 923 | + | got, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{ | |||
| 924 | + | Label: "short", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, ExpiresAt: exp, | |||
| 925 | + | }) | |||
| 926 | + | require.NoError(t, err) | |||
| 927 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 928 | + | c.pushAs(got.Secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |||
| 929 | + | job := runner.await() | |||
| 930 | + | c.heartbeat(runner, job) | |||
| 931 | + | assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |||
| 932 | + | time.Sleep(time.Until(exp) + 200*time.Millisecond) | |||
| 933 | + | assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError()) | |||
| 934 | + | }) | |||
| 935 | + | ||||
| 936 | + | t.Run("the repository of the run is always readable by its job", func(t *testing.T) { | |||
| 937 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 938 | + | secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj") | |||
| 939 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 940 | + | c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()}) | |||
| 941 | + | job := runner.await() | |||
| 942 | + | c.heartbeat(runner, job) | |||
| 943 | + | got := c.resolve(runner, job, "owner/proj@main") | |||
| 944 | + | require.Empty(t, got[0].GetError()) | |||
| 945 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource()) | |||
| 946 | + | }) | |||
| 947 | + | ||||
| 948 | + | t.Run("a merge made by the owner's session", func(t *testing.T) { | |||
| 949 | + | // The runs of merged requests record the owner: a principal that reads | |||
| 950 | + | // everything. | |||
| 951 | + | p := domain.PusherOf(domain.Owner()) | |||
| 952 | + | assert.Equal(t, domain.CIPusherOwner, p.Kind) | |||
| 953 | + | }) | |||
| 954 | + | } | |||
| 955 | + | ||||
| 956 | + | // GO-001b: the archive token is bound to (repository, commit). Only the archive | |||
| 957 | + | // endpoint reads it, only for that commit; it is refused by Git over HTTP, the | |||
| 958 | + | // repository and hash-algorithm endpoints, and for any other commit, ref or | |||
| 959 | + | // repository, public or not. | |||
| 960 | + | func TestArchiveTokenIsBoundToItsCommit(t *testing.T) { | |||
| 961 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 962 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js") | |||
| 963 | + | first := c.bareGit("myaction", "rev-parse", "HEAD") | |||
| 964 | + | c.mustGit(c.repoDir("myaction"), "tag", "v1") | |||
| 965 | + | // A second commit, to have a sha the token was not issued for. | |||
| 966 | + | other := c.workTree("main", "action.yml", "dist/index.js", "more.js") | |||
| 967 | + | c.mustGit(other, "push", "-q", "-f", c.urlAuth(adminToken, "/myaction.git"), "main") | |||
| 968 | + | second := c.bareGit("myaction", "rev-parse", "HEAD") | |||
| 969 | + | require.NotEqual(t, first, second) | |||
| 970 | + | c.seed("openrepo", domain.VisibilityPublic, "a.txt") | |||
| 971 | + | openSha := c.bareGit("openrepo", "rev-parse", "HEAD") | |||
| 972 | + | ||||
| 973 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 974 | + | job := c.startJob(runner) | |||
| 975 | + | c.heartbeat(runner, job) | |||
| 976 | + | got := c.resolve(runner, job, "owner/myaction@v1", "owner/myaction@"+second) | |||
| 977 | + | require.Empty(t, got[0].GetError()) | |||
| 978 | + | require.Empty(t, got[1].GetError()) | |||
| 979 | + | assert.Equal(t, first, got[0].GetResolvedSha()) | |||
| 980 | + | assert.Equal(t, second, got[1].GetResolvedSha()) | |||
| 981 | + | assert.NotEqual(t, got[0].GetToken(), got[1].GetToken(), "one token per commit") | |||
| 982 | + | arch := got[0].GetToken() | |||
| 983 | + | tarball := func(repo, ref, tok string) int { | |||
| 984 | + | status, _, _ := c.get("/-/github/api/repos/owner/"+repo+"/tarball/"+ref, basic(tok)) | |||
| 985 | + | return status | |||
| 986 | + | } | |||
| 987 | + | ||||
| 988 | + | // What it is for: that commit, named by its sha or by a ref that resolves to it. | |||
| 989 | + | assert.Equal(t, http.StatusOK, tarball("myaction", first, arch)) | |||
| 990 | + | assert.Equal(t, http.StatusOK, tarball("myaction", "v1", arch), "any ref that resolves to the commit") | |||
| 991 | + | assert.Equal(t, http.StatusOK, tarball("MyAction", first, arch), "names are case-insensitive") | |||
| 992 | + | status, _, hdr := c.head("/-/github/api/repos/owner/myaction/tarball/"+first, basic(arch)) | |||
| 993 | + | assert.Equal(t, http.StatusOK, status) | |||
| 994 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-myaction-"+first[:7]) | |||
| 995 | + | ||||
| 996 | + | // Nothing else. | |||
| 997 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", second, arch), "another commit of the repository") | |||
| 998 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", "main", arch), "a ref that now points elsewhere") | |||
| 999 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", "", arch), "the default branch") | |||
| 1000 | + | assert.Equal(t, http.StatusNotFound, tarball("proj", "main", arch), "the repository of the run") | |||
| 1001 | + | assert.Equal(t, http.StatusNotFound, tarball("openrepo", openSha, arch), "not even a public repository") | |||
| 1002 | + | assert.Equal(t, http.StatusUnauthorized, tarball("myaction", first, arch+"x"), "a token that is not one") | |||
| 1003 | + | status, _, _ = c.head("/-/github/api/repos/owner/myaction/tarball/"+second, basic(arch)) | |||
| 1004 | + | assert.Equal(t, http.StatusNotFound, status, "HEAD is checked like GET") | |||
| 1005 | + | for _, p := range []string{ | |||
| 1006 | + | "/owner/myaction/info/refs?service=git-upload-pack", | |||
| 1007 | + | "/owner/myaction.git/info/refs?service=git-upload-pack", | |||
| 1008 | + | "/myaction/info/refs?service=git-upload-pack", | |||
| 1009 | + | "/owner/myaction/info/refs?service=git-receive-pack", | |||
| 1010 | + | "/owner/openrepo/info/refs?service=git-upload-pack", | |||
| 1011 | + | "/-/github/api/repos/owner/myaction", | |||
| 1012 | + | "/-/github/api/repos/owner/myaction/hash-algorithm", | |||
| 1013 | + | "/-/github/api/repos/owner/openrepo", | |||
| 1014 | + | "/-/github/api/repos/owner/openrepo/hash-algorithm", | |||
| 1015 | + | } { | |||
| 1016 | + | status, _, _ := c.get(p, basic(arch)) | |||
| 1017 | + | assert.Contains(t, []int{http.StatusNotFound, http.StatusUnauthorized}, status, p) | |||
| 1018 | + | } | |||
| 1019 | + | // It lists no repository over the API either, and holds no scope there. | |||
| 1020 | + | list, err := c.api(arch).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{})) | |||
| 1021 | + | require.NoError(t, err) | |||
| 1022 | + | names := []string{} | |||
| 1023 | + | for _, r := range list.Msg.GetRepos() { | |||
| 1024 | + | names = append(names, r.GetName()) | |||
| 1025 | + | } | |||
| 1026 | + | assert.Empty(t, names, "it reads nothing, not even the public repositories an anonymous caller reads") | |||
| 1027 | + | _, err = c.api(arch).repo.GetRepo(c.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "myaction"})) | |||
| 1028 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |||
| 1029 | + | ||||
| 1030 | + | // The job's own token is the opposite: it reads its repository whole, and | |||
| 1031 | + | // the archive of nothing else. | |||
| 1032 | + | own := job.GetGithubToken() | |||
| 1033 | + | assert.Equal(t, http.StatusNotFound, tarball("myaction", first, own)) | |||
| 1034 | + | assert.Equal(t, http.StatusOK, tarball("proj", "main", own)) | |||
| 1035 | + | ||||
| 1036 | + | // Principals. | |||
| 1037 | + | p, err := c.forge.Auth.Authenticate(c.ctx, arch) | |||
| 1038 | + | require.NoError(t, err) | |||
| 1039 | + | repo, sha, ok := p.ArchiveGrant() | |||
| 1040 | + | require.True(t, ok) | |||
| 1041 | + | assert.Equal(t, "myaction", repo.String()) | |||
| 1042 | + | assert.Equal(t, first, sha) | |||
| 1043 | + | assert.Empty(t, p.Scopes()) | |||
| 1044 | + | assert.False(t, p.HasScope(domain.ScopeRepoRead)) | |||
| 1045 | + | assert.False(t, p.Allows(domain.ScopeRepoRead, repo)) | |||
| 1046 | + | assert.False(t, p.IsRoot()) | |||
| 1047 | + | ownP, err := c.forge.Auth.Authenticate(c.ctx, own) | |||
| 1048 | + | require.NoError(t, err) | |||
| 1049 | + | _, _, ok = ownP.ArchiveGrant() | |||
| 1050 | + | assert.False(t, ok) | |||
| 1051 | + | } | |||
| 1052 | + | ||||
| 1053 | + | func (c *ciHarness) head(path, authorization string) (int, string, http.Header) { | |||
| 1054 | + | c.t.Helper() | |||
| 1055 | + | req, err := http.NewRequest(http.MethodHead, c.url(path), nil) | |||
| 1056 | + | require.NoError(c.t, err) | |||
| 1057 | + | if authorization != "" { | |||
| 1058 | + | req.Header.Set("Authorization", authorization) | |||
| 1059 | + | } | |||
| 1060 | + | resp, err := http.DefaultClient.Do(req) | |||
| 1061 | + | require.NoError(c.t, err) | |||
| 1062 | + | defer func() { _ = resp.Body.Close() }() | |||
| 1063 | + | b, _ := io.ReadAll(resp.Body) | |||
| 1064 | + | return resp.StatusCode, string(b), resp.Header | |||
| 1065 | + | } | |||
| 1066 | + | ||||
| 1067 | + | // GO-002: a HEAD of the tarball answers the headers of the archive of the ref it | |||
| 1068 | + | // resolves, runs no git archive, and 404s a ref that does not resolve. | |||
| 1069 | + | func TestTarballHeadRunsNoArchive(t *testing.T) { | |||
| 1070 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 1071 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 1072 | + | job := c.startJob(runner) | |||
| 1073 | + | tok := job.GetGithubToken() | |||
| 1074 | + | sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |||
| 1075 | + | ||||
| 1076 | + | status, body, hdr := c.head("/-/github/api/repos/owner/proj/tarball/main", basic(tok)) | |||
| 1077 | + | assert.Equal(t, http.StatusOK, status) | |||
| 1078 | + | assert.Empty(t, body, "no body") | |||
| 1079 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz") | |||
| 1080 | + | assert.Contains(t, hdr.Get("Content-Type"), "gzip") | |||
| 1081 | + | status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/nope", basic(tok)) | |||
| 1082 | + | assert.Equal(t, http.StatusNotFound, status) | |||
| 1083 | + | status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/main", "") | |||
| 1084 | + | assert.Equal(t, http.StatusNotFound, status, "private and anonymous") | |||
| 1085 | + | status, _, _ = c.head("/-/github/api/repos/owner/other/tarball/main", basic(tok)) | |||
| 1086 | + | assert.Equal(t, http.StatusNotFound, status) | |||
| 1087 | + | } | |||
| 1088 | + | ||||
| 1089 | + | // GO-003: one client cannot take every archive slot, and the slots are given | |||
| 1090 | + | // back. | |||
| 1091 | + | func TestArchiveDownloadsAreLimitedPerClient(t *testing.T) { | |||
| 1092 | + | h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.archivesPerClient = 1 }) | |||
| 1093 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |||
| 1094 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |||
| 1095 | + | require.NoError(t, err) | |||
| 1096 | + | c.wt = h.workTree("main") | |||
| 1097 | + | c.writeFile(c.wt, "big.bin", bytes.Repeat([]byte("0123456789abcdef"), 1<<20)) // 16 MiB, compressible | |||
| 1098 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main") | |||
| 1099 | + | ||||
| 1100 | + | // Start a download and read none of it: the response is held open. | |||
| 1101 | + | req, err := http.NewRequest(http.MethodGet, c.url("/-/github/api/repos/owner/proj/tarball/main"), nil) | |||
| 1102 | + | require.NoError(t, err) | |||
| 1103 | + | req.Header.Set("Authorization", "Bearer "+adminToken) | |||
| 1104 | + | resp, err := http.DefaultClient.Do(req) | |||
| 1105 | + | require.NoError(t, err) | |||
| 1106 | + | ||||
| 1107 | + | var status int | |||
| 1108 | + | c.waitFor("the second download is refused", func() bool { | |||
| 1109 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |||
| 1110 | + | return status == http.StatusTooManyRequests | |||
| 1111 | + | }) | |||
| 1112 | + | assert.Equal(t, http.StatusTooManyRequests, status) | |||
| 1113 | + | hs, _, _ := c.head("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |||
| 1114 | + | assert.Equal(t, http.StatusOK, hs, "a HEAD takes no slot") | |||
| 1115 | + | metaStatus, _, _ := c.get("/-/github/api/repos/owner/proj", "Bearer "+adminToken) | |||
| 1116 | + | assert.Equal(t, http.StatusOK, metaStatus, "nor does the repository endpoint") | |||
| 1117 | + | ||||
| 1118 | + | // Reading it to the end gives the slot back. | |||
| 1119 | + | _, err = io.Copy(io.Discard, resp.Body) | |||
| 1120 | + | require.NoError(t, err) | |||
| 1121 | + | _ = resp.Body.Close() | |||
| 1122 | + | c.waitFor("the slot is given back", func() bool { | |||
| 1123 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |||
| 1124 | + | return status == http.StatusOK | |||
| 1125 | + | }) | |||
| 1126 | + | } | |||
| 1127 | + | ||||
| 1128 | + | // GO-004: owner and repository are validated with GitHub's charset for every | |||
| 1129 | + | // source; nothing but a name reaches a URL, a path or a command. | |||
| 1130 | + | func TestResolveActionsRejectsMalformedNames(t *testing.T) { | |||
| 1131 | + | remote := newFakeRemote(t) | |||
| 1132 | + | c := newExecHarness(t, remote, nil) | |||
| 1133 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 1134 | + | job := c.startJob(runner) | |||
| 1135 | + | c.heartbeat(runner, job) | |||
| 1136 | + | ||||
| 1137 | + | bad := []string{ | |||
| 1138 | + | "../..@" + shaMain, ".../x@" + shaMain, "../x@" + shaMain, "..//x@" + shaMain, "a/..@" + shaMain, "../b@" + shaMain, | |||
| 1139 | + | "a/b%2e@" + shaMain, "a%2fb/c@" + shaMain, "a/b?x=1@" + shaMain, "a/b#f@" + shaMain, "a b/c@" + shaMain, | |||
| 1140 | + | "a/b c@" + shaMain, "a:b/c@" + shaMain, "a/b\n@" + shaMain, "a@b/c@v1", "a/b\x00@v1", "a/...@v1", "a/.@v1", | |||
| 1141 | + | "é/b@v1", strings.Repeat("a", 101) + "/b@v1", "a/" + strings.Repeat("b", 101) + "@v1", "/@v1", "@@", | |||
| 1142 | + | } | |||
| 1143 | + | got := c.resolve(runner, job, bad...) | |||
| 1144 | + | require.Len(t, got, len(bad)) | |||
| 1145 | + | for i, g := range got { | |||
| 1146 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual '"+bad[i]+"'", g.GetError(), "%q", bad[i]) | |||
| 1147 | + | assert.Empty(t, g.GetTarballUrl(), "%q", bad[i]) | |||
| 1148 | + | assert.Empty(t, g.GetToken(), "%q", bad[i]) | |||
| 1149 | + | } | |||
| 1150 | + | assert.Zero(t, remote.count(), "no malformed name reached ls-remote") | |||
| 1151 | + | ||||
| 1152 | + | // Names GitHub accepts still do. | |||
| 1153 | + | remote.set("a.b/c_d-e", ptrRef("refs/tags/v1", shaMain)) | |||
| 1154 | + | ok := c.resolve(runner, job, "a.b/c_d-e@v1", "Owner/repo/sub@"+shaMain) | |||
| 1155 | + | assert.Empty(t, ok[0].GetError()) | |||
| 1156 | + | assert.Empty(t, ok[1].GetError()) | |||
| 1157 | + | } | |||
| 1158 | + | ||||
| 1159 | + | // A token revoked, a job lost, the server restarted: the archive token goes with | |||
| 1160 | + | // the job, and it was never written anywhere but hashed. | |||
| 1161 | + | func TestArchiveTokenIsRevokedWithTheJobAfterARestart(t *testing.T) { | |||
| 1162 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |||
| 1163 | + | cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 600*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |||
| 1164 | + | }) | |||
| 1165 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |||
| 1166 | + | sha := c.bareGit("myaction", "rev-parse", "HEAD") | |||
| 1167 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 1168 | + | job := c.startJob(runner) | |||
| 1169 | + | c.heartbeat(runner, job) | |||
| 1170 | + | arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken() | |||
| 1171 | + | require.NotEmpty(t, arch) | |||
| 1172 | + | url := "/-/github/api/repos/owner/myaction/tarball/" + sha | |||
| 1173 | + | status, _, _ := c.get(url, basic(arch)) | |||
| 1174 | + | require.Equal(t, http.StatusOK, status) | |||
| 1175 | + | ||||
| 1176 | + | // Restart: the tokens are in the store, the secrets in memory are gone. | |||
| 1177 | + | cfg, hookExe := c.forge.cfg, binary(t) | |||
| 1178 | + | c.srv.Close() | |||
| 1179 | + | c.forge.Close(c.ctx) | |||
| 1180 | + | f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, remoteLister: newFakeRemote(t).m, ci: func(cc *usecase.CIConfig) { | |||
| 1181 | + | cc.HeartbeatDeadline, cc.Tick = 600*time.Millisecond, 20*time.Millisecond | |||
| 1182 | + | }}) | |||
| 1183 | + | require.NoError(t, err) | |||
| 1184 | + | t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) }) | |||
| 1185 | + | srv := httptest.NewServer(f2.server.Handler()) | |||
| 1186 | + | t.Cleanup(srv.Close) | |||
| 1187 | + | ||||
| 1188 | + | get := func(tok string) int { | |||
| 1189 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+url, nil) | |||
| 1190 | + | req.Header.Set("Authorization", basic(tok)) | |||
| 1191 | + | resp, err := http.DefaultClient.Do(req) | |||
| 1192 | + | require.NoError(t, err) | |||
| 1193 | + | _ = resp.Body.Close() | |||
| 1194 | + | return resp.StatusCode | |||
| 1195 | + | } | |||
| 1196 | + | assert.Equal(t, http.StatusOK, get(arch), "the token survives a restart") | |||
| 1197 | + | ||||
| 1198 | + | // The runner is gone: the heartbeat deadline of the new server passes, the | |||
| 1199 | + | // job is lost and its tokens with it. | |||
| 1200 | + | require.Eventually(t, func() bool { return get(arch) == http.StatusUnauthorized }, 10*time.Second, 50*time.Millisecond) | |||
| 1201 | + | assert.Equal(t, http.StatusUnauthorized, get(job.GetGithubToken()), "the job's own token as well") | |||
| 1202 | + | api := newClients(srv.URL+"/-/api", adminToken) | |||
| 1203 | + | res, err := api.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: job.GetRunId()})) | |||
| 1204 | + | require.NoError(t, err) | |||
| 1205 | + | assert.Contains(t, res.Msg.GetJobs()[0].GetError(), "lost communication") | |||
| 1206 | + | } | |||
| 1207 | + | ||||
| 1208 | + | // GO-006: the archive tokens that read a repository go when it is deleted, and | |||
| 1209 | + | // the run that held one goes on without a stale index. | |||
| 1210 | + | func TestArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) { | |||
| 1211 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 1212 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |||
| 1213 | + | sha := c.bareGit("myaction", "rev-parse", "HEAD") | |||
| 1214 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 1215 | + | job := c.startJob(runner) | |||
| 1216 | + | c.heartbeat(runner, job) | |||
| 1217 | + | arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken() | |||
| 1218 | + | _, err := c.forge.Auth.Authenticate(c.ctx, arch) | |||
| 1219 | + | require.NoError(t, err) | |||
| 1220 | + | ||||
| 1221 | + | require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("myaction"))) | |||
| 1222 | + | _, err = c.forge.Auth.Authenticate(c.ctx, arch) | |||
| 1223 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "deleted with the repository it reads") | |||
| 1224 | + | _, err = c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken()) | |||
| 1225 | + | assert.NoError(t, err, "the job's own token is the run's repository's, not touched") | |||
| 1226 | + | ||||
| 1227 | + | // A repository created again under the name does not inherit the token. | |||
| 1228 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |||
| 1229 | + | _, err = c.forge.Auth.Authenticate(c.ctx, arch) | |||
| 1230 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 1231 | + | status, _, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+sha, basic(arch)) | |||
| 1232 | + | assert.Equal(t, http.StatusUnauthorized, status) | |||
| 1233 | + | ||||
| 1234 | + | // The job ending afterwards revokes what is left without an error. | |||
| 1235 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |||
| 1236 | + | } | |||
| 1237 | + | ||||
| 1238 | + | // GO-005: ls-remote runs are bounded server-wide and a call has a deadline. | |||
| 1239 | + | func TestResolveActionsBoundsRemoteWork(t *testing.T) { | |||
| 1240 | + | t.Run("concurrency", func(t *testing.T) { | |||
| 1241 | + | // The jobs of a run resolve at the same time: five calls, two slots. | |||
| 1242 | + | var running, peak atomic.Int32 | |||
| 1243 | + | release := make(chan struct{}) | |||
| 1244 | + | remote := newFakeRemote(t) | |||
| 1245 | + | remote.m.ExpectedCalls = nil | |||
| 1246 | + | remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 1247 | + | func(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |||
| 1248 | + | n := running.Add(1) | |||
| 1249 | + | defer running.Add(-1) | |||
| 1250 | + | for { | |||
| 1251 | + | p := peak.Load() | |||
| 1252 | + | if n <= p || peak.CompareAndSwap(p, n) { | |||
| 1253 | + | break | |||
| 1254 | + | } | |||
| 1255 | + | } | |||
| 1256 | + | select { | |||
| 1257 | + | case <-release: | |||
| 1258 | + | case <-ctx.Done(): | |||
| 1259 | + | return nil, ctx.Err() | |||
| 1260 | + | } | |||
| 1261 | + | return []ports.RemoteRef{{Name: "refs/tags/v1", ID: shaMain}}, nil | |||
| 1262 | + | }).Maybe() | |||
| 1263 | + | c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.RemoteConcurrency = 2 }) | |||
| 1264 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 1265 | + | job := c.startJob(runner) | |||
| 1266 | + | c.heartbeat(runner, job) | |||
| 1267 | + | ||||
| 1268 | + | var wg sync.WaitGroup | |||
| 1269 | + | results := make([][]*pb.ResolvedAction, 5) | |||
| 1270 | + | errs := make([]error, 5) | |||
| 1271 | + | for i, repo := range []string{"one", "two", "three", "four", "five"} { | |||
| 1272 | + | wg.Add(1) | |||
| 1273 | + | go func() { | |||
| 1274 | + | defer wg.Done() | |||
| 1275 | + | results[i], errs[i] = c.resolveErr(runner, job, "a/"+repo+"@v1") | |||
| 1276 | + | }() | |||
| 1277 | + | } | |||
| 1278 | + | require.Eventually(t, func() bool { return running.Load() == 2 }, 5*time.Second, 10*time.Millisecond) | |||
| 1279 | + | time.Sleep(150 * time.Millisecond) | |||
| 1280 | + | assert.EqualValues(t, 2, running.Load(), "the others wait for a slot") | |||
| 1281 | + | close(release) | |||
| 1282 | + | wg.Wait() | |||
| 1283 | + | assert.LessOrEqual(t, peak.Load(), int32(2)) | |||
| 1284 | + | for i := range results { | |||
| 1285 | + | require.NoError(t, errs[i]) | |||
| 1286 | + | assert.Empty(t, results[i][0].GetError(), results[i][0].GetRef()) | |||
| 1287 | + | } | |||
| 1288 | + | }) | |||
| 1289 | + | ||||
| 1290 | + | t.Run("deadline", func(t *testing.T) { | |||
| 1291 | + | remote := newFakeRemote(t) | |||
| 1292 | + | remote.m.ExpectedCalls = nil | |||
| 1293 | + | remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 1294 | + | func(ctx context.Context, _, _ string) ([]ports.RemoteRef, error) { | |||
| 1295 | + | <-ctx.Done() | |||
| 1296 | + | return nil, ctx.Err() | |||
| 1297 | + | }).Maybe() | |||
| 1298 | + | c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.ResolveTimeout = 300 * time.Millisecond }) | |||
| 1299 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 1300 | + | job := c.startJob(runner) | |||
| 1301 | + | c.heartbeat(runner, job) | |||
| 1302 | + | start := time.Now() | |||
| 1303 | + | _, err := c.resolveErr(runner, job, "slow/action@v1") | |||
| 1304 | + | assert.Equal(t, connect.CodeUnavailable, ciErrCode(err), "a deadline of ours is unavailable, not deadline_exceeded") | |||
| 1305 | + | assert.Less(t, time.Since(start), 5*time.Second) | |||
| 1306 | + | // The job goes on: the next call is answered. | |||
| 1307 | + | ok, err := c.resolveErr(runner, job, "a/b@"+shaMain) | |||
| 1308 | + | require.NoError(t, err) | |||
| 1309 | + | assert.Empty(t, ok[0].GetError()) | |||
| 1310 | + | }) | |||
| 1311 | + | } | |||
M internal/app/forge.go +6 -1
| @@ -52,6 +52,9 @@ type options struct { | |||
| 52 | 52 | eventBuffer int | |
| 53 | 53 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | |
| 54 | 54 | revalidateEvery time.Duration | |
| 55 | + | // archivesPerClient overrides the archives one client downloads at once | |
| 56 | + | // when positive. | |
| 57 | + | archivesPerClient int | |
| 55 | 58 | // remote replaces the lister of github.com refs (tests never reach the | |
| 56 | 59 | // network). | |
| 57 | 60 | remoteLister ports.GitRemote | |
| @@ -215,7 +218,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 215 | 218 | PollTimeout: cfg.CIPollTimeout, Archive: git, | |
| 216 | 219 | Remote: opts.remote(gitremote.New(gitremote.Config{ | |
| 217 | 220 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | |
| 221 | + | Proxy: gitremote.ProxyEnv(os.Getenv), | |
| 218 | 222 | })), | |
| 223 | + | Tokens: store.Tokens(), | |
| 219 | 224 | } | |
| 220 | 225 | if opts.ci != nil { | |
| 221 | 226 | opts.ci(&ciCfg) | |
| @@ -312,7 +317,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 312 | 317 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 313 | 318 | } | |
| 314 | 319 | // The REST subset the actions call on github.api_url. | |
| 315 | - | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter}) | |
| 320 | + | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient}) | |
| 316 | 321 | for _, p := range githubapi.Patterns { | |
| 317 | 322 | routes = append(routes, httpserver.Route{Pattern: p, Handler: api}) | |
| 318 | 323 | } | |
| @@ -52,6 +52,9 @@ type options struct { | |||||
| 52 | eventBuffer int | 52 | eventBuffer int | ||
| 53 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | 53 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | ||
| 54 | revalidateEvery time.Duration | 54 | revalidateEvery time.Duration | ||
| 55 | + | // archivesPerClient overrides the archives one client downloads at once | |||
| 56 | + | // when positive. | |||
| 57 | + | archivesPerClient int | |||
| 55 | // remote replaces the lister of github.com refs (tests never reach the | 58 | // remote replaces the lister of github.com refs (tests never reach the | ||
| 56 | // network). | 59 | // network). | ||
| 57 | remoteLister ports.GitRemote | 60 | remoteLister ports.GitRemote | ||
| @@ -215,7 +218,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 215 | PollTimeout: cfg.CIPollTimeout, Archive: git, | 218 | PollTimeout: cfg.CIPollTimeout, Archive: git, | ||
| 216 | Remote: opts.remote(gitremote.New(gitremote.Config{ | 219 | Remote: opts.remote(gitremote.New(gitremote.Config{ | ||
| 217 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | 220 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | ||
| 221 | + | Proxy: gitremote.ProxyEnv(os.Getenv), | |||
| 218 | })), | 222 | })), | ||
| 223 | + | Tokens: store.Tokens(), | |||
| 219 | } | 224 | } | ||
| 220 | if opts.ci != nil { | 225 | if opts.ci != nil { | ||
| 221 | opts.ci(&ciCfg) | 226 | opts.ci(&ciCfg) | ||
| @@ -312,7 +317,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 312 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | 317 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | ||
| 313 | } | 318 | } | ||
| 314 | // The REST subset the actions call on github.api_url. | 319 | // The REST subset the actions call on github.api_url. | ||
| 315 | - | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter}) | 320 | + | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient}) |
| 316 | for _, p := range githubapi.Patterns { | 321 | for _, p := range githubapi.Patterns { | ||
| 317 | routes = append(routes, httpserver.Route{Pattern: p, Handler: api}) | 322 | routes = append(routes, httpserver.Route{Pattern: p, Handler: api}) | ||
| 318 | } | 323 | } | ||
M internal/domain/ci.go +3 -0
| @@ -191,6 +191,9 @@ type CIRunSource struct { | |||
| 191 | 191 | Repo RepoName | |
| 192 | 192 | WorkflowYAML string | |
| 193 | 193 | GithubJSON string | |
| 194 | + | // Pusher is who pushed the commit: what the actions of other repositories | |
| 195 | + | // of the server may be resolved on behalf of. | |
| 196 | + | Pusher CIPusher | |
| 194 | 197 | } | |
| 195 | 198 | ||
| 196 | 199 | // CIStep is a step of a job with where its lines are in the job log. | |
| @@ -191,6 +191,9 @@ type CIRunSource struct { | |||||
| 191 | Repo RepoName | 191 | Repo RepoName | ||
| 192 | WorkflowYAML string | 192 | WorkflowYAML string | ||
| 193 | GithubJSON string | 193 | GithubJSON string | ||
| 194 | + | // Pusher is who pushed the commit: what the actions of other repositories | |||
| 195 | + | // of the server may be resolved on behalf of. | |||
| 196 | + | Pusher CIPusher | |||
| 194 | } | 197 | } | ||
| 195 | 198 | ||||
| 196 | // CIStep is a step of a job with where its lines are in the job log. | 199 | // CIStep is a step of a job with where its lines are in the job log. | ||
M internal/domain/ci_token.go +59 -7
| @@ -37,8 +37,11 @@ type CIRepoToken struct { | |||
| 37 | 37 | RunAttempt int32 | |
| 38 | 38 | JobID string | |
| 39 | 39 | // Archive is set for the token of an action archive, unset for the job's | |
| 40 | - | // own token ("github.token"). | |
| 41 | - | Archive bool | |
| 40 | + | // own token ("github.token"). An archive token downloads the commit Sha of | |
| 41 | + | // Repo and nothing else. | |
| 42 | + | Archive bool | |
| 43 | + | // Sha is the commit an archive token is bound to; empty for the job's own. | |
| 44 | + | Sha string | |
| 42 | 45 | ExpiresAt time.Time | |
| 43 | 46 | CreatedAt time.Time | |
| 44 | 47 | } | |
| @@ -51,18 +54,67 @@ func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresA | |||
| 51 | 54 | const RepoTokenGrace = 5 * time.Minute | |
| 52 | 55 | ||
| 53 | 56 | // RepoTokenPrincipal returns the principal of the holder of a job repository | |
| 54 | - | // token. It is shaped like the principal of a stored token (the scope | |
| 55 | - | // repo:read on one repository, an expiry), so that every authorization rule | |
| 56 | - | // of the forge applies to it unchanged: it reads that repository, even when | |
| 57 | - | // private, and writes nothing. Its TokenID starts with "job:" and matches no | |
| 58 | - | // stored token. | |
| 57 | + | // token. | |
| 58 | + | // | |
| 59 | + | // The job's own token is shaped like a stored token (the scope repo:read on one | |
| 60 | + | // repository, an expiry), so that every authorization rule of the forge applies | |
| 61 | + | // to it unchanged: it reads that repository, even when private, and writes | |
| 62 | + | // nothing. | |
| 63 | + | // | |
| 64 | + | // An archive token holds no scope at all: it is the grant to download one | |
| 65 | + | // commit of one repository as an archive, which only the archive endpoint | |
| 66 | + | // reads (Principal.ArchiveGrant). Git over HTTP, the repository endpoint and | |
| 67 | + | // the rest of the API see a caller with no rights, so a private repository | |
| 68 | + | // does not show itself to it. | |
| 69 | + | // | |
| 70 | + | // Both have a TokenID that starts with "job:" and matches no stored token. | |
| 59 | 71 | func RepoTokenPrincipal(t CIRepoToken) Principal { | |
| 72 | + | if t.Archive { | |
| 73 | + | return Principal{ | |
| 74 | + | kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt, | |
| 75 | + | archiveRepo: t.Repo, archiveSha: t.Sha, | |
| 76 | + | } | |
| 77 | + | } | |
| 60 | 78 | return TokenPrincipal(Token{ | |
| 61 | 79 | ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead}, | |
| 62 | 80 | Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, | |
| 63 | 81 | }) | |
| 64 | 82 | } | |
| 65 | 83 | ||
| 84 | + | // CIPusherKind tells who pushed the commit of a run. | |
| 85 | + | type CIPusherKind string | |
| 86 | + | ||
| 87 | + | // Pusher kinds. The zero value is a run stored before the pusher was recorded. | |
| 88 | + | const ( | |
| 89 | + | CIPusherNone CIPusherKind = "" | |
| 90 | + | CIPusherRoot CIPusherKind = "root" | |
| 91 | + | CIPusherOwner CIPusherKind = "owner" | |
| 92 | + | CIPusherToken CIPusherKind = "token" | |
| 93 | + | ) | |
| 94 | + | ||
| 95 | + | // CIPusher is who pushed the commit of a run, as recorded with the run. A | |
| 96 | + | // token is recorded by its ID only: what it may read is looked up when it is | |
| 97 | + | // needed, so a token revoked since no longer reads anything on behalf of the | |
| 98 | + | // run. | |
| 99 | + | type CIPusher struct { | |
| 100 | + | Kind CIPusherKind | |
| 101 | + | TokenID string | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // PusherOf records the pusher p. Anonymous callers, which cannot push, and the | |
| 105 | + | // holders of job tokens, which cannot either, are recorded as nobody. | |
| 106 | + | func PusherOf(p Principal) CIPusher { | |
| 107 | + | switch { | |
| 108 | + | case p.IsOwner(): | |
| 109 | + | return CIPusher{Kind: CIPusherOwner} | |
| 110 | + | case p.IsRoot(): | |
| 111 | + | return CIPusher{Kind: CIPusherRoot} | |
| 112 | + | case p.TokenID() != "" && !IsRepoTokenID(p.TokenID()): | |
| 113 | + | return CIPusher{Kind: CIPusherToken, TokenID: p.TokenID()} | |
| 114 | + | } | |
| 115 | + | return CIPusher{} | |
| 116 | + | } | |
| 117 | + | ||
| 66 | 118 | // CIActionSource says where an action was found. | |
| 67 | 119 | type CIActionSource int | |
| 68 | 120 | ||
| @@ -37,8 +37,11 @@ type CIRepoToken struct { | |||||
| 37 | RunAttempt int32 | 37 | RunAttempt int32 | ||
| 38 | JobID string | 38 | JobID string | ||
| 39 | // Archive is set for the token of an action archive, unset for the job's | 39 | // Archive is set for the token of an action archive, unset for the job's | ||
| 40 | - | // own token ("github.token"). | 40 | + | // own token ("github.token"). An archive token downloads the commit Sha of |
| 41 | - | Archive bool | 41 | + | // Repo and nothing else. |
| 42 | + | Archive bool | |||
| 43 | + | // Sha is the commit an archive token is bound to; empty for the job's own. | |||
| 44 | + | Sha string | |||
| 42 | ExpiresAt time.Time | 45 | ExpiresAt time.Time | ||
| 43 | CreatedAt time.Time | 46 | CreatedAt time.Time | ||
| 44 | } | 47 | } | ||
| @@ -51,18 +54,67 @@ func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresA | |||||
| 51 | const RepoTokenGrace = 5 * time.Minute | 54 | const RepoTokenGrace = 5 * time.Minute | ||
| 52 | 55 | ||||
| 53 | // RepoTokenPrincipal returns the principal of the holder of a job repository | 56 | // RepoTokenPrincipal returns the principal of the holder of a job repository | ||
| 54 | - | // token. It is shaped like the principal of a stored token (the scope | 57 | + | // token. |
| 55 | - | // repo:read on one repository, an expiry), so that every authorization rule | 58 | + | // |
| 56 | - | // of the forge applies to it unchanged: it reads that repository, even when | 59 | + | // The job's own token is shaped like a stored token (the scope repo:read on one |
| 57 | - | // private, and writes nothing. Its TokenID starts with "job:" and matches no | 60 | + | // repository, an expiry), so that every authorization rule of the forge applies |
| 58 | - | // stored token. | 61 | + | // to it unchanged: it reads that repository, even when private, and writes |
| 62 | + | // nothing. | |||
| 63 | + | // | |||
| 64 | + | // An archive token holds no scope at all: it is the grant to download one | |||
| 65 | + | // commit of one repository as an archive, which only the archive endpoint | |||
| 66 | + | // reads (Principal.ArchiveGrant). Git over HTTP, the repository endpoint and | |||
| 67 | + | // the rest of the API see a caller with no rights, so a private repository | |||
| 68 | + | // does not show itself to it. | |||
| 69 | + | // | |||
| 70 | + | // Both have a TokenID that starts with "job:" and matches no stored token. | |||
| 59 | func RepoTokenPrincipal(t CIRepoToken) Principal { | 71 | func RepoTokenPrincipal(t CIRepoToken) Principal { | ||
| 72 | + | if t.Archive { | |||
| 73 | + | return Principal{ | |||
| 74 | + | kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt, | |||
| 75 | + | archiveRepo: t.Repo, archiveSha: t.Sha, | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 60 | return TokenPrincipal(Token{ | 78 | return TokenPrincipal(Token{ | ||
| 61 | ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead}, | 79 | ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead}, | ||
| 62 | Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, | 80 | Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, | ||
| 63 | }) | 81 | }) | ||
| 64 | } | 82 | } | ||
| 65 | 83 | ||||
| 84 | + | // CIPusherKind tells who pushed the commit of a run. | |||
| 85 | + | type CIPusherKind string | |||
| 86 | + | ||||
| 87 | + | // Pusher kinds. The zero value is a run stored before the pusher was recorded. | |||
| 88 | + | const ( | |||
| 89 | + | CIPusherNone CIPusherKind = "" | |||
| 90 | + | CIPusherRoot CIPusherKind = "root" | |||
| 91 | + | CIPusherOwner CIPusherKind = "owner" | |||
| 92 | + | CIPusherToken CIPusherKind = "token" | |||
| 93 | + | ) | |||
| 94 | + | ||||
| 95 | + | // CIPusher is who pushed the commit of a run, as recorded with the run. A | |||
| 96 | + | // token is recorded by its ID only: what it may read is looked up when it is | |||
| 97 | + | // needed, so a token revoked since no longer reads anything on behalf of the | |||
| 98 | + | // run. | |||
| 99 | + | type CIPusher struct { | |||
| 100 | + | Kind CIPusherKind | |||
| 101 | + | TokenID string | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // PusherOf records the pusher p. Anonymous callers, which cannot push, and the | |||
| 105 | + | // holders of job tokens, which cannot either, are recorded as nobody. | |||
| 106 | + | func PusherOf(p Principal) CIPusher { | |||
| 107 | + | switch { | |||
| 108 | + | case p.IsOwner(): | |||
| 109 | + | return CIPusher{Kind: CIPusherOwner} | |||
| 110 | + | case p.IsRoot(): | |||
| 111 | + | return CIPusher{Kind: CIPusherRoot} | |||
| 112 | + | case p.TokenID() != "" && !IsRepoTokenID(p.TokenID()): | |||
| 113 | + | return CIPusher{Kind: CIPusherToken, TokenID: p.TokenID()} | |||
| 114 | + | } | |||
| 115 | + | return CIPusher{} | |||
| 116 | + | } | |||
| 117 | + | ||||
| 66 | // CIActionSource says where an action was found. | 118 | // CIActionSource says where an action was found. | ||
| 67 | type CIActionSource int | 119 | type CIActionSource int | ||
| 68 | 120 | ||||
M internal/domain/ci_token_test.go +53 -0
| @@ -51,3 +51,56 @@ func TestRepoTokenPrincipalReadsOneRepositoryAndNothingElse(t *testing.T) { | |||
| 51 | 51 | assert.False(t, tok.Expired(exp.Add(-time.Second))) | |
| 52 | 52 | assert.True(t, tok.Expired(exp), "the expiry instant is already over") | |
| 53 | 53 | } | |
| 54 | + | ||
| 55 | + | func TestArchivePrincipalHoldsNoScopeAndOneGrant(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 57 | + | repo, _ := domain.ParseRepoName("action") | |
| 58 | + | sha := strings.Repeat("a", 40) | |
| 59 | + | exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) | |
| 60 | + | p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab", Repo: repo, Archive: true, Sha: sha, ExpiresAt: exp}) | |
| 61 | + | ||
| 62 | + | gr, gs, ok := p.ArchiveGrant() | |
| 63 | + | assert.True(t, ok) | |
| 64 | + | assert.Equal(t, repo, gr) | |
| 65 | + | assert.Equal(t, sha, gs) | |
| 66 | + | for _, s := range domain.AllScopes() { | |
| 67 | + | assert.False(t, p.HasScope(s), s) | |
| 68 | + | assert.False(t, p.Allows(s, repo), s) | |
| 69 | + | } | |
| 70 | + | assert.False(t, p.IsRoot()) | |
| 71 | + | assert.False(t, p.IsAnonymous(), "it is authenticated, with nothing to read") | |
| 72 | + | assert.False(t, p.HasOwnerRights()) | |
| 73 | + | assert.Equal(t, exp, p.ExpiresAt()) | |
| 74 | + | assert.True(t, domain.IsRepoTokenID(p.TokenID())) | |
| 75 | + | assert.Equal(t, "archive", p.Label()) | |
| 76 | + | ||
| 77 | + | own := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, ExpiresAt: exp}) | |
| 78 | + | _, _, ok = own.ArchiveGrant() | |
| 79 | + | assert.False(t, ok, "the job's own token has no grant, it has the scope") | |
| 80 | + | for _, other := range []domain.Principal{domain.Anonymous(), domain.Root(), domain.Owner()} { | |
| 81 | + | _, _, ok = other.ArchiveGrant() | |
| 82 | + | assert.False(t, ok) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestPusherOf(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | repo, _ := domain.ParseRepoName("proj") | |
| 89 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherRoot}, domain.PusherOf(domain.Root())) | |
| 90 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherOwner}, domain.PusherOf(domain.Owner())) | |
| 91 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "abc"}, | |
| 92 | + | domain.PusherOf(domain.TokenPrincipal(domain.Token{ID: "abc", Scopes: []domain.Scope{domain.ScopeRepoWrite}}))) | |
| 93 | + | assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.Anonymous())) | |
| 94 | + | assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "x", Repo: repo})), "a job token never pushes") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestOwnerMatches(t *testing.T) { | |
| 98 | + | t.Parallel() | |
| 99 | + | assert.True(t, domain.OwnerMatches("acme", "acme")) | |
| 100 | + | assert.True(t, domain.OwnerMatches("acme", "ACME")) | |
| 101 | + | assert.True(t, domain.OwnerMatches("acme", "Acme")) | |
| 102 | + | assert.False(t, domain.OwnerMatches("acme", "acme2")) | |
| 103 | + | assert.False(t, domain.OwnerMatches("acme", "")) | |
| 104 | + | assert.False(t, domain.OwnerMatches("", ""), "no owner configured matches nothing") | |
| 105 | + | assert.False(t, domain.OwnerMatches("", "acme")) | |
| 106 | + | } | |
| @@ -51,3 +51,56 @@ func TestRepoTokenPrincipalReadsOneRepositoryAndNothingElse(t *testing.T) { | |||||
| 51 | assert.False(t, tok.Expired(exp.Add(-time.Second))) | 51 | assert.False(t, tok.Expired(exp.Add(-time.Second))) | ||
| 52 | assert.True(t, tok.Expired(exp), "the expiry instant is already over") | 52 | assert.True(t, tok.Expired(exp), "the expiry instant is already over") | ||
| 53 | } | 53 | } | ||
| 54 | + | ||||
| 55 | + | func TestArchivePrincipalHoldsNoScopeAndOneGrant(t *testing.T) { | |||
| 56 | + | t.Parallel() | |||
| 57 | + | repo, _ := domain.ParseRepoName("action") | |||
| 58 | + | sha := strings.Repeat("a", 40) | |||
| 59 | + | exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) | |||
| 60 | + | p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab", Repo: repo, Archive: true, Sha: sha, ExpiresAt: exp}) | |||
| 61 | + | ||||
| 62 | + | gr, gs, ok := p.ArchiveGrant() | |||
| 63 | + | assert.True(t, ok) | |||
| 64 | + | assert.Equal(t, repo, gr) | |||
| 65 | + | assert.Equal(t, sha, gs) | |||
| 66 | + | for _, s := range domain.AllScopes() { | |||
| 67 | + | assert.False(t, p.HasScope(s), s) | |||
| 68 | + | assert.False(t, p.Allows(s, repo), s) | |||
| 69 | + | } | |||
| 70 | + | assert.False(t, p.IsRoot()) | |||
| 71 | + | assert.False(t, p.IsAnonymous(), "it is authenticated, with nothing to read") | |||
| 72 | + | assert.False(t, p.HasOwnerRights()) | |||
| 73 | + | assert.Equal(t, exp, p.ExpiresAt()) | |||
| 74 | + | assert.True(t, domain.IsRepoTokenID(p.TokenID())) | |||
| 75 | + | assert.Equal(t, "archive", p.Label()) | |||
| 76 | + | ||||
| 77 | + | own := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, ExpiresAt: exp}) | |||
| 78 | + | _, _, ok = own.ArchiveGrant() | |||
| 79 | + | assert.False(t, ok, "the job's own token has no grant, it has the scope") | |||
| 80 | + | for _, other := range []domain.Principal{domain.Anonymous(), domain.Root(), domain.Owner()} { | |||
| 81 | + | _, _, ok = other.ArchiveGrant() | |||
| 82 | + | assert.False(t, ok) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestPusherOf(t *testing.T) { | |||
| 87 | + | t.Parallel() | |||
| 88 | + | repo, _ := domain.ParseRepoName("proj") | |||
| 89 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherRoot}, domain.PusherOf(domain.Root())) | |||
| 90 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherOwner}, domain.PusherOf(domain.Owner())) | |||
| 91 | + | assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "abc"}, | |||
| 92 | + | domain.PusherOf(domain.TokenPrincipal(domain.Token{ID: "abc", Scopes: []domain.Scope{domain.ScopeRepoWrite}}))) | |||
| 93 | + | assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.Anonymous())) | |||
| 94 | + | assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "x", Repo: repo})), "a job token never pushes") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestOwnerMatches(t *testing.T) { | |||
| 98 | + | t.Parallel() | |||
| 99 | + | assert.True(t, domain.OwnerMatches("acme", "acme")) | |||
| 100 | + | assert.True(t, domain.OwnerMatches("acme", "ACME")) | |||
| 101 | + | assert.True(t, domain.OwnerMatches("acme", "Acme")) | |||
| 102 | + | assert.False(t, domain.OwnerMatches("acme", "acme2")) | |||
| 103 | + | assert.False(t, domain.OwnerMatches("acme", "")) | |||
| 104 | + | assert.False(t, domain.OwnerMatches("", ""), "no owner configured matches nothing") | |||
| 105 | + | assert.False(t, domain.OwnerMatches("", "acme")) | |||
| 106 | + | } | |||
M internal/domain/name.go +9 -0
| @@ -101,3 +101,12 @@ func nameProblem(s string) string { | |||
| 101 | 101 | } | |
| 102 | 102 | return "" | |
| 103 | 103 | } | |
| 104 | + | ||
| 105 | + | // OwnerMatches reports whether name is the owner of the forge, as GitHub | |
| 106 | + | // matches the owner of a repository: without regard to case. It is the one | |
| 107 | + | // rule for the owner segment of the Git URLs, of the REST subset and of the | |
| 108 | + | // references of actions. configured is ERMITE_OWNER, which is lower case (it | |
| 109 | + | // follows the rules of repository names); an empty one matches nothing. | |
| 110 | + | func OwnerMatches(configured, name string) bool { | |
| 111 | + | return configured != "" && strings.EqualFold(configured, name) | |
| 112 | + | } | |
| @@ -101,3 +101,12 @@ func nameProblem(s string) string { | |||||
| 101 | } | 101 | } | ||
| 102 | return "" | 102 | return "" | ||
| 103 | } | 103 | } | ||
| 104 | + | ||||
| 105 | + | // OwnerMatches reports whether name is the owner of the forge, as GitHub | |||
| 106 | + | // matches the owner of a repository: without regard to case. It is the one | |||
| 107 | + | // rule for the owner segment of the Git URLs, of the REST subset and of the | |||
| 108 | + | // references of actions. configured is ERMITE_OWNER, which is lower case (it | |||
| 109 | + | // follows the rules of repository names); an empty one matches nothing. | |||
| 110 | + | func OwnerMatches(configured, name string) bool { | |||
| 111 | + | return configured != "" && strings.EqualFold(configured, name) | |||
| 112 | + | } | |||
M internal/domain/principal.go +20 -0
| @@ -17,6 +17,10 @@ const ( | |||
| 17 | 17 | kindRoot | |
| 18 | 18 | kindToken | |
| 19 | 19 | kindOwner | |
| 20 | + | // kindArchive is the holder of the archive token of an action: it holds no | |
| 21 | + | // scope at all, only a grant to download one commit of one repository (see | |
| 22 | + | // ArchivePrincipal). | |
| 23 | + | kindArchive | |
| 20 | 24 | ) | |
| 21 | 25 | ||
| 22 | 26 | // Principal is the authenticated identity of a caller: nobody, the root | |
| @@ -31,6 +35,9 @@ type Principal struct { | |||
| 31 | 35 | branchPrefixes []string | |
| 32 | 36 | expiresAt time.Time | |
| 33 | 37 | label string | |
| 38 | + | // archiveRepo and archiveSha are the grant of a kindArchive principal. | |
| 39 | + | archiveRepo RepoName | |
| 40 | + | archiveSha string | |
| 34 | 41 | } | |
| 35 | 42 | ||
| 36 | 43 | // Anonymous returns the principal of a caller without credentials. | |
| @@ -67,6 +74,8 @@ func (p Principal) Label() string { | |||
| 67 | 74 | return "owner" | |
| 68 | 75 | case kindToken: | |
| 69 | 76 | return "token" | |
| 77 | + | case kindArchive: | |
| 78 | + | return "archive" | |
| 70 | 79 | } | |
| 71 | 80 | return "anonymous" | |
| 72 | 81 | } | |
| @@ -187,3 +196,14 @@ func (p Principal) Allows(s Scope, repo RepoName) bool { | |||
| 187 | 196 | // TokenLabel returns the label of the stored token, or "" for other | |
| 188 | 197 | // principals. | |
| 189 | 198 | func (p Principal) TokenLabel() string { return p.label } | |
| 199 | + | ||
| 200 | + | // ArchiveGrant returns the one commit of one repository that p may download as | |
| 201 | + | // an archive, and ok true, when p is the holder of an archive token. Only the | |
| 202 | + | // archive endpoint of the REST subset reads it: for everything else an archive | |
| 203 | + | // principal holds no scope, so it reads what an anonymous caller reads. | |
| 204 | + | func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) { | |
| 205 | + | if p.kind != kindArchive { | |
| 206 | + | return RepoName{}, "", false | |
| 207 | + | } | |
| 208 | + | return p.archiveRepo, p.archiveSha, true | |
| 209 | + | } | |
| @@ -17,6 +17,10 @@ const ( | |||||
| 17 | kindRoot | 17 | kindRoot | ||
| 18 | kindToken | 18 | kindToken | ||
| 19 | kindOwner | 19 | kindOwner | ||
| 20 | + | // kindArchive is the holder of the archive token of an action: it holds no | |||
| 21 | + | // scope at all, only a grant to download one commit of one repository (see | |||
| 22 | + | // ArchivePrincipal). | |||
| 23 | + | kindArchive | |||
| 20 | ) | 24 | ) | ||
| 21 | 25 | ||||
| 22 | // Principal is the authenticated identity of a caller: nobody, the root | 26 | // Principal is the authenticated identity of a caller: nobody, the root | ||
| @@ -31,6 +35,9 @@ type Principal struct { | |||||
| 31 | branchPrefixes []string | 35 | branchPrefixes []string | ||
| 32 | expiresAt time.Time | 36 | expiresAt time.Time | ||
| 33 | label string | 37 | label string | ||
| 38 | + | // archiveRepo and archiveSha are the grant of a kindArchive principal. | |||
| 39 | + | archiveRepo RepoName | |||
| 40 | + | archiveSha string | |||
| 34 | } | 41 | } | ||
| 35 | 42 | ||||
| 36 | // Anonymous returns the principal of a caller without credentials. | 43 | // Anonymous returns the principal of a caller without credentials. | ||
| @@ -67,6 +74,8 @@ func (p Principal) Label() string { | |||||
| 67 | return "owner" | 74 | return "owner" | ||
| 68 | case kindToken: | 75 | case kindToken: | ||
| 69 | return "token" | 76 | return "token" | ||
| 77 | + | case kindArchive: | |||
| 78 | + | return "archive" | |||
| 70 | } | 79 | } | ||
| 71 | return "anonymous" | 80 | return "anonymous" | ||
| 72 | } | 81 | } | ||
| @@ -187,3 +196,14 @@ func (p Principal) Allows(s Scope, repo RepoName) bool { | |||||
| 187 | // TokenLabel returns the label of the stored token, or "" for other | 196 | // TokenLabel returns the label of the stored token, or "" for other | ||
| 188 | // principals. | 197 | // principals. | ||
| 189 | func (p Principal) TokenLabel() string { return p.label } | 198 | func (p Principal) TokenLabel() string { return p.label } | ||
| 199 | + | ||||
| 200 | + | // ArchiveGrant returns the one commit of one repository that p may download as | |||
| 201 | + | // an archive, and ok true, when p is the holder of an archive token. Only the | |||
| 202 | + | // archive endpoint of the REST subset reads it: for everything else an archive | |||
| 203 | + | // principal holds no scope, so it reads what an anonymous caller reads. | |||
| 204 | + | func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) { | |||
| 205 | + | if p.kind != kindArchive { | |||
| 206 | + | return RepoName{}, "", false | |||
| 207 | + | } | |||
| 208 | + | return p.archiveRepo, p.archiveSha, true | |||
| 209 | + | } | |||
M internal/ports/mocks/GitHubAPIService.go +84 -0
| @@ -286,3 +286,87 @@ func (_c *GitHubAPIService_Tarball_Call) RunAndReturn(run func(ctx context.Conte | |||
| 286 | 286 | _c.Call.Return(run) | |
| 287 | 287 | return _c | |
| 288 | 288 | } | |
| 289 | + | ||
| 290 | + | // TarballName provides a mock function for the type GitHubAPIService | |
| 291 | + | func (_mock *GitHubAPIService) TarballName(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error) { | |
| 292 | + | ret := _mock.Called(ctx, p, owner, repo, ref) | |
| 293 | + | ||
| 294 | + | if len(ret) == 0 { | |
| 295 | + | panic("no return value specified for TarballName") | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | var r0 string | |
| 299 | + | var r1 error | |
| 300 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) (string, error)); ok { | |
| 301 | + | return returnFunc(ctx, p, owner, repo, ref) | |
| 302 | + | } | |
| 303 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) string); ok { | |
| 304 | + | r0 = returnFunc(ctx, p, owner, repo, ref) | |
| 305 | + | } else { | |
| 306 | + | r0 = ret.Get(0).(string) | |
| 307 | + | } | |
| 308 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, string) error); ok { | |
| 309 | + | r1 = returnFunc(ctx, p, owner, repo, ref) | |
| 310 | + | } else { | |
| 311 | + | r1 = ret.Error(1) | |
| 312 | + | } | |
| 313 | + | return r0, r1 | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // GitHubAPIService_TarballName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TarballName' | |
| 317 | + | type GitHubAPIService_TarballName_Call struct { | |
| 318 | + | *mock.Call | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // TarballName is a helper method to define mock.On call | |
| 322 | + | // - ctx context.Context | |
| 323 | + | // - p domain.Principal | |
| 324 | + | // - owner string | |
| 325 | + | // - repo string | |
| 326 | + | // - ref string | |
| 327 | + | func (_e *GitHubAPIService_Expecter) TarballName(ctx any, p any, owner any, repo any, ref any) *GitHubAPIService_TarballName_Call { | |
| 328 | + | return &GitHubAPIService_TarballName_Call{Call: _e.mock.On("TarballName", ctx, p, owner, repo, ref)} | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func (_c *GitHubAPIService_TarballName_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string)) *GitHubAPIService_TarballName_Call { | |
| 332 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 333 | + | var arg0 context.Context | |
| 334 | + | if args[0] != nil { | |
| 335 | + | arg0 = args[0].(context.Context) | |
| 336 | + | } | |
| 337 | + | var arg1 domain.Principal | |
| 338 | + | if args[1] != nil { | |
| 339 | + | arg1 = args[1].(domain.Principal) | |
| 340 | + | } | |
| 341 | + | var arg2 string | |
| 342 | + | if args[2] != nil { | |
| 343 | + | arg2 = args[2].(string) | |
| 344 | + | } | |
| 345 | + | var arg3 string | |
| 346 | + | if args[3] != nil { | |
| 347 | + | arg3 = args[3].(string) | |
| 348 | + | } | |
| 349 | + | var arg4 string | |
| 350 | + | if args[4] != nil { | |
| 351 | + | arg4 = args[4].(string) | |
| 352 | + | } | |
| 353 | + | run( | |
| 354 | + | arg0, | |
| 355 | + | arg1, | |
| 356 | + | arg2, | |
| 357 | + | arg3, | |
| 358 | + | arg4, | |
| 359 | + | ) | |
| 360 | + | }) | |
| 361 | + | return _c | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func (_c *GitHubAPIService_TarballName_Call) Return(s string, err error) *GitHubAPIService_TarballName_Call { | |
| 365 | + | _c.Call.Return(s, err) | |
| 366 | + | return _c | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func (_c *GitHubAPIService_TarballName_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error)) *GitHubAPIService_TarballName_Call { | |
| 370 | + | _c.Call.Return(run) | |
| 371 | + | return _c | |
| 372 | + | } | |
| @@ -286,3 +286,87 @@ func (_c *GitHubAPIService_Tarball_Call) RunAndReturn(run func(ctx context.Conte | |||||
| 286 | _c.Call.Return(run) | 286 | _c.Call.Return(run) | ||
| 287 | return _c | 287 | return _c | ||
| 288 | } | 288 | } | ||
| 289 | + | ||||
| 290 | + | // TarballName provides a mock function for the type GitHubAPIService | |||
| 291 | + | func (_mock *GitHubAPIService) TarballName(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error) { | |||
| 292 | + | ret := _mock.Called(ctx, p, owner, repo, ref) | |||
| 293 | + | ||||
| 294 | + | if len(ret) == 0 { | |||
| 295 | + | panic("no return value specified for TarballName") | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | var r0 string | |||
| 299 | + | var r1 error | |||
| 300 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) (string, error)); ok { | |||
| 301 | + | return returnFunc(ctx, p, owner, repo, ref) | |||
| 302 | + | } | |||
| 303 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) string); ok { | |||
| 304 | + | r0 = returnFunc(ctx, p, owner, repo, ref) | |||
| 305 | + | } else { | |||
| 306 | + | r0 = ret.Get(0).(string) | |||
| 307 | + | } | |||
| 308 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, string) error); ok { | |||
| 309 | + | r1 = returnFunc(ctx, p, owner, repo, ref) | |||
| 310 | + | } else { | |||
| 311 | + | r1 = ret.Error(1) | |||
| 312 | + | } | |||
| 313 | + | return r0, r1 | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // GitHubAPIService_TarballName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TarballName' | |||
| 317 | + | type GitHubAPIService_TarballName_Call struct { | |||
| 318 | + | *mock.Call | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // TarballName is a helper method to define mock.On call | |||
| 322 | + | // - ctx context.Context | |||
| 323 | + | // - p domain.Principal | |||
| 324 | + | // - owner string | |||
| 325 | + | // - repo string | |||
| 326 | + | // - ref string | |||
| 327 | + | func (_e *GitHubAPIService_Expecter) TarballName(ctx any, p any, owner any, repo any, ref any) *GitHubAPIService_TarballName_Call { | |||
| 328 | + | return &GitHubAPIService_TarballName_Call{Call: _e.mock.On("TarballName", ctx, p, owner, repo, ref)} | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func (_c *GitHubAPIService_TarballName_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string)) *GitHubAPIService_TarballName_Call { | |||
| 332 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 333 | + | var arg0 context.Context | |||
| 334 | + | if args[0] != nil { | |||
| 335 | + | arg0 = args[0].(context.Context) | |||
| 336 | + | } | |||
| 337 | + | var arg1 domain.Principal | |||
| 338 | + | if args[1] != nil { | |||
| 339 | + | arg1 = args[1].(domain.Principal) | |||
| 340 | + | } | |||
| 341 | + | var arg2 string | |||
| 342 | + | if args[2] != nil { | |||
| 343 | + | arg2 = args[2].(string) | |||
| 344 | + | } | |||
| 345 | + | var arg3 string | |||
| 346 | + | if args[3] != nil { | |||
| 347 | + | arg3 = args[3].(string) | |||
| 348 | + | } | |||
| 349 | + | var arg4 string | |||
| 350 | + | if args[4] != nil { | |||
| 351 | + | arg4 = args[4].(string) | |||
| 352 | + | } | |||
| 353 | + | run( | |||
| 354 | + | arg0, | |||
| 355 | + | arg1, | |||
| 356 | + | arg2, | |||
| 357 | + | arg3, | |||
| 358 | + | arg4, | |||
| 359 | + | ) | |||
| 360 | + | }) | |||
| 361 | + | return _c | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | func (_c *GitHubAPIService_TarballName_Call) Return(s string, err error) *GitHubAPIService_TarballName_Call { | |||
| 365 | + | _c.Call.Return(s, err) | |||
| 366 | + | return _c | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func (_c *GitHubAPIService_TarballName_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error)) *GitHubAPIService_TarballName_Call { | |||
| 370 | + | _c.Call.Return(run) | |||
| 371 | + | return _c | |||
| 372 | + | } | |||
M internal/ports/services.go +6 -1
| @@ -67,10 +67,15 @@ type GitHubAPIService interface { | |||
| 67 | 67 | HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error) | |
| 68 | 68 | // Tarball resolves ref (a branch, a tag or a commit id; empty means the | |
| 69 | 69 | // default branch) and writes the gzip-compressed tar of the commit to the | |
| 70 | - | // writer that begin returns. begin is called once, with the file name of the | |
| 70 | + | // writer that begin returns; the archive token of an action (see | |
| 71 | + | // domain.RepoTokenPrincipal) gets the commit it was issued for and no other. | |
| 72 | + | // begin is called once, with the file name of the | |
| 71 | 73 | // archive, before the first byte and not at all when the ref does not | |
| 72 | 74 | // resolve (domain.ErrNotFound). | |
| 73 | 75 | Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error | |
| 76 | + | // TarballName is Tarball for a HEAD request: the same access and the same | |
| 77 | + | // resolution of ref, then the file name, without producing the archive. | |
| 78 | + | TarballName(ctx context.Context, p domain.Principal, owner, repo, ref string) (string, error) | |
| 74 | 79 | } | |
| 75 | 80 | ||
| 76 | 81 | // BrowseService is the read side of a repository's content. *usecase.Browse | |
| @@ -67,10 +67,15 @@ type GitHubAPIService interface { | |||||
| 67 | HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error) | 67 | HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error) | ||
| 68 | // Tarball resolves ref (a branch, a tag or a commit id; empty means the | 68 | // Tarball resolves ref (a branch, a tag or a commit id; empty means the | ||
| 69 | // default branch) and writes the gzip-compressed tar of the commit to the | 69 | // default branch) and writes the gzip-compressed tar of the commit to the | ||
| 70 | - | // writer that begin returns. begin is called once, with the file name of the | 70 | + | // writer that begin returns; the archive token of an action (see |
| 71 | + | // domain.RepoTokenPrincipal) gets the commit it was issued for and no other. | |||
| 72 | + | // begin is called once, with the file name of the | |||
| 71 | // archive, before the first byte and not at all when the ref does not | 73 | // archive, before the first byte and not at all when the ref does not | ||
| 72 | // resolve (domain.ErrNotFound). | 74 | // resolve (domain.ErrNotFound). | ||
| 73 | Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error | 75 | Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error | ||
| 76 | + | // TarballName is Tarball for a HEAD request: the same access and the same | |||
| 77 | + | // resolution of ref, then the file name, without producing the archive. | |||
| 78 | + | TarballName(ctx context.Context, p domain.Principal, owner, repo, ref string) (string, error) | |||
| 74 | } | 79 | } | ||
| 75 | 80 | ||||
| 76 | // BrowseService is the read side of a repository's content. *usecase.Browse | 81 | // BrowseService is the read side of a repository's content. *usecase.Browse | ||
M internal/usecase/access.go +6 -0
| @@ -20,6 +20,12 @@ func canRead(p domain.Principal, repo domain.Repo) bool { | |||
| 20 | 20 | // canReadAs is canRead for a repository known by its name and visibility only, | |
| 21 | 21 | // such as one that no longer exists. | |
| 22 | 22 | func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool { | |
| 23 | + | if _, _, archive := p.ArchiveGrant(); archive { | |
| 24 | + | // The holder of an archive token downloads one commit through the | |
| 25 | + | // archive endpoint, which checks its grant; everywhere else it reads | |
| 26 | + | // nothing, not even a public repository (domain.RepoTokenPrincipal). | |
| 27 | + | return false | |
| 28 | + | } | |
| 23 | 29 | if vis == domain.VisibilityPublic { | |
| 24 | 30 | return true | |
| 25 | 31 | } | |
| @@ -20,6 +20,12 @@ func canRead(p domain.Principal, repo domain.Repo) bool { | |||||
| 20 | // canReadAs is canRead for a repository known by its name and visibility only, | 20 | // canReadAs is canRead for a repository known by its name and visibility only, | ||
| 21 | // such as one that no longer exists. | 21 | // such as one that no longer exists. | ||
| 22 | func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool { | 22 | func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool { | ||
| 23 | + | if _, _, archive := p.ArchiveGrant(); archive { | |||
| 24 | + | // The holder of an archive token downloads one commit through the | |||
| 25 | + | // archive endpoint, which checks its grant; everywhere else it reads | |||
| 26 | + | // nothing, not even a public repository (domain.RepoTokenPrincipal). | |||
| 27 | + | return false | |||
| 28 | + | } | |||
| 23 | if vis == domain.VisibilityPublic { | 29 | if vis == domain.VisibilityPublic { | ||
| 24 | return true | 30 | return true | ||
| 25 | } | 31 | } | ||
M internal/usecase/ci.go +29 -10
| @@ -29,6 +29,11 @@ const ( | |||
| 29 | 29 | DefaultCIKeepalive = 30 * time.Second | |
| 30 | 30 | // DefaultCITriggerQueue is the size of the queue of pushes to plan. | |
| 31 | 31 | DefaultCITriggerQueue = 256 | |
| 32 | + | // DefaultCIRemoteConcurrency is the git ls-remote processes ResolveActions | |
| 33 | + | // runs at once, whatever the number of jobs that resolve. | |
| 34 | + | DefaultCIRemoteConcurrency = 4 | |
| 35 | + | // DefaultCIResolveTimeout bounds one ResolveActions call. | |
| 36 | + | DefaultCIResolveTimeout = 60 * time.Second | |
| 32 | 37 | // MaxCIChecks is the most checks ListChecks returns. | |
| 33 | 38 | MaxCIChecks = 1000 | |
| 34 | 39 | // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log | |
| @@ -61,6 +66,15 @@ type CIConfig struct { | |||
| 61 | 66 | Remote ports.GitRemote | |
| 62 | 67 | // Archive serves the tarballs of the REST subset. | |
| 63 | 68 | Archive ports.GitArchive | |
| 69 | + | // Tokens finds the stored token of the pusher of a run, to ask what it may | |
| 70 | + | // read when an action of another repository is resolved for the run. Nil | |
| 71 | + | // reads nothing for a token (fail closed). | |
| 72 | + | Tokens ports.TokenStore | |
| 73 | + | // RemoteConcurrency bounds the git ls-remote processes at once (default | |
| 74 | + | // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call | |
| 75 | + | // (default DefaultCIResolveTimeout). | |
| 76 | + | RemoteConcurrency int | |
| 77 | + | ResolveTimeout time.Duration | |
| 64 | 78 | // Owner is the owner login of the github context. | |
| 65 | 79 | Owner string | |
| 66 | 80 | // PublicURL is the base URL of the instance (github.server_url). | |
| @@ -95,6 +109,10 @@ func (c CIConfig) withDefaults() CIConfig { | |||
| 95 | 109 | def(&c.TimeoutSlack, domain.CITimeoutSlack) | |
| 96 | 110 | def(&c.QueueTimeout, domain.CIQueueTimeout) | |
| 97 | 111 | def(&c.Retention, domain.CIRetentionDays*24*time.Hour) | |
| 112 | + | if c.RemoteConcurrency <= 0 { | |
| 113 | + | c.RemoteConcurrency = DefaultCIRemoteConcurrency | |
| 114 | + | } | |
| 115 | + | def(&c.ResolveTimeout, DefaultCIResolveTimeout) | |
| 98 | 116 | if c.TriggerQueue <= 0 { | |
| 99 | 117 | c.TriggerQueue = DefaultCITriggerQueue | |
| 100 | 118 | } | |
| @@ -117,21 +135,22 @@ type CI struct { | |||
| 117 | 135 | ctx context.Context | |
| 118 | 136 | wg sync.WaitGroup | |
| 119 | 137 | ||
| 120 | - | locks sync.Map // run id -> *sync.Mutex | |
| 121 | - | assign sync.Mutex | |
| 122 | - | jobs notifier // woken when a job changes: key is the job reference | |
| 123 | - | pending notifier // woken when a job may be ready or a runner was removed | |
| 124 | - | flows *wfCache | |
| 138 | + | remoteSlots chan struct{} // slots of the git ls-remote processes | |
| 139 | + | locks sync.Map // run id -> *sync.Mutex | |
| 140 | + | assign sync.Mutex | |
| 141 | + | jobs notifier // woken when a job changes: key is the job reference | |
| 142 | + | pending notifier // woken when a job may be ready or a runner was removed | |
| 143 | + | flows *wfCache | |
| 125 | 144 | ||
| 126 | 145 | mu sync.Mutex | |
| 127 | 146 | heartbeats map[string]time.Time // job reference -> last heartbeat | |
| 128 | 147 | seen map[string]time.Time // runner id -> last seen | |
| 129 | 148 | polls map[string]pollRecord | |
| 130 | 149 | // archiveTokens are the secrets of the tokens given for the archives of | |
| 131 | - | // ermite actions, per job reference then repository, so that a job that | |
| 132 | - | // resolves the same repository twice gets one token (memory only: a | |
| 150 | + | // ermite actions, per job reference then "<repository>@<sha>", so that a job | |
| 151 | + | // that resolves the same commit twice gets one token (memory only: a | |
| 133 | 152 | // restart issues new ones). | |
| 134 | - | archiveTokens map[string]map[domain.RepoName]string | |
| 153 | + | archiveTokens map[string]map[string]string | |
| 135 | 154 | streams int | |
| 136 | 155 | perClient map[string]int | |
| 137 | 156 | closed bool | |
| @@ -164,9 +183,9 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||
| 164 | 183 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | |
| 165 | 184 | return &CI{ | |
| 166 | 185 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), | |
| 167 | - | ctx: run, stop: stop, flows: newWfCache(64), | |
| 186 | + | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), | |
| 168 | 187 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | |
| 169 | - | archiveTokens: map[string]map[domain.RepoName]string{}, | |
| 188 | + | archiveTokens: map[string]map[string]string{}, | |
| 170 | 189 | perClient: map[string]int{}, | |
| 171 | 190 | } | |
| 172 | 191 | } | |
| @@ -29,6 +29,11 @@ const ( | |||||
| 29 | DefaultCIKeepalive = 30 * time.Second | 29 | DefaultCIKeepalive = 30 * time.Second | ||
| 30 | // DefaultCITriggerQueue is the size of the queue of pushes to plan. | 30 | // DefaultCITriggerQueue is the size of the queue of pushes to plan. | ||
| 31 | DefaultCITriggerQueue = 256 | 31 | DefaultCITriggerQueue = 256 | ||
| 32 | + | // DefaultCIRemoteConcurrency is the git ls-remote processes ResolveActions | |||
| 33 | + | // runs at once, whatever the number of jobs that resolve. | |||
| 34 | + | DefaultCIRemoteConcurrency = 4 | |||
| 35 | + | // DefaultCIResolveTimeout bounds one ResolveActions call. | |||
| 36 | + | DefaultCIResolveTimeout = 60 * time.Second | |||
| 32 | // MaxCIChecks is the most checks ListChecks returns. | 37 | // MaxCIChecks is the most checks ListChecks returns. | ||
| 33 | MaxCIChecks = 1000 | 38 | MaxCIChecks = 1000 | ||
| 34 | // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log | 39 | // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log | ||
| @@ -61,6 +66,15 @@ type CIConfig struct { | |||||
| 61 | Remote ports.GitRemote | 66 | Remote ports.GitRemote | ||
| 62 | // Archive serves the tarballs of the REST subset. | 67 | // Archive serves the tarballs of the REST subset. | ||
| 63 | Archive ports.GitArchive | 68 | Archive ports.GitArchive | ||
| 69 | + | // Tokens finds the stored token of the pusher of a run, to ask what it may | |||
| 70 | + | // read when an action of another repository is resolved for the run. Nil | |||
| 71 | + | // reads nothing for a token (fail closed). | |||
| 72 | + | Tokens ports.TokenStore | |||
| 73 | + | // RemoteConcurrency bounds the git ls-remote processes at once (default | |||
| 74 | + | // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call | |||
| 75 | + | // (default DefaultCIResolveTimeout). | |||
| 76 | + | RemoteConcurrency int | |||
| 77 | + | ResolveTimeout time.Duration | |||
| 64 | // Owner is the owner login of the github context. | 78 | // Owner is the owner login of the github context. | ||
| 65 | Owner string | 79 | Owner string | ||
| 66 | // PublicURL is the base URL of the instance (github.server_url). | 80 | // PublicURL is the base URL of the instance (github.server_url). | ||
| @@ -95,6 +109,10 @@ func (c CIConfig) withDefaults() CIConfig { | |||||
| 95 | def(&c.TimeoutSlack, domain.CITimeoutSlack) | 109 | def(&c.TimeoutSlack, domain.CITimeoutSlack) | ||
| 96 | def(&c.QueueTimeout, domain.CIQueueTimeout) | 110 | def(&c.QueueTimeout, domain.CIQueueTimeout) | ||
| 97 | def(&c.Retention, domain.CIRetentionDays*24*time.Hour) | 111 | def(&c.Retention, domain.CIRetentionDays*24*time.Hour) | ||
| 112 | + | if c.RemoteConcurrency <= 0 { | |||
| 113 | + | c.RemoteConcurrency = DefaultCIRemoteConcurrency | |||
| 114 | + | } | |||
| 115 | + | def(&c.ResolveTimeout, DefaultCIResolveTimeout) | |||
| 98 | if c.TriggerQueue <= 0 { | 116 | if c.TriggerQueue <= 0 { | ||
| 99 | c.TriggerQueue = DefaultCITriggerQueue | 117 | c.TriggerQueue = DefaultCITriggerQueue | ||
| 100 | } | 118 | } | ||
| @@ -117,21 +135,22 @@ type CI struct { | |||||
| 117 | ctx context.Context | 135 | ctx context.Context | ||
| 118 | wg sync.WaitGroup | 136 | wg sync.WaitGroup | ||
| 119 | 137 | ||||
| 120 | - | locks sync.Map // run id -> *sync.Mutex | 138 | + | remoteSlots chan struct{} // slots of the git ls-remote processes |
| 121 | - | assign sync.Mutex | 139 | + | locks sync.Map // run id -> *sync.Mutex |
| 122 | - | jobs notifier // woken when a job changes: key is the job reference | 140 | + | assign sync.Mutex |
| 123 | - | pending notifier // woken when a job may be ready or a runner was removed | 141 | + | jobs notifier // woken when a job changes: key is the job reference |
| 124 | - | flows *wfCache | 142 | + | pending notifier // woken when a job may be ready or a runner was removed |
| 143 | + | flows *wfCache | |||
| 125 | 144 | ||||
| 126 | mu sync.Mutex | 145 | mu sync.Mutex | ||
| 127 | heartbeats map[string]time.Time // job reference -> last heartbeat | 146 | heartbeats map[string]time.Time // job reference -> last heartbeat | ||
| 128 | seen map[string]time.Time // runner id -> last seen | 147 | seen map[string]time.Time // runner id -> last seen | ||
| 129 | polls map[string]pollRecord | 148 | polls map[string]pollRecord | ||
| 130 | // archiveTokens are the secrets of the tokens given for the archives of | 149 | // archiveTokens are the secrets of the tokens given for the archives of | ||
| 131 | - | // ermite actions, per job reference then repository, so that a job that | 150 | + | // ermite actions, per job reference then "<repository>@<sha>", so that a job |
| 132 | - | // resolves the same repository twice gets one token (memory only: a | 151 | + | // that resolves the same commit twice gets one token (memory only: a |
| 133 | // restart issues new ones). | 152 | // restart issues new ones). | ||
| 134 | - | archiveTokens map[string]map[domain.RepoName]string | 153 | + | archiveTokens map[string]map[string]string |
| 135 | streams int | 154 | streams int | ||
| 136 | perClient map[string]int | 155 | perClient map[string]int | ||
| 137 | closed bool | 156 | closed bool | ||
| @@ -164,9 +183,9 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||||
| 164 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | 183 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | ||
| 165 | return &CI{ | 184 | return &CI{ | ||
| 166 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), | 185 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), | ||
| 167 | - | ctx: run, stop: stop, flows: newWfCache(64), | 186 | + | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), |
| 168 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | 187 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | ||
| 169 | - | archiveTokens: map[string]map[domain.RepoName]string{}, | 188 | + | archiveTokens: map[string]map[string]string{}, |
| 170 | perClient: map[string]int{}, | 189 | perClient: map[string]int{}, | ||
| 171 | } | 190 | } | ||
| 172 | } | 191 | } | ||
M internal/usecase/ci_actions.go +135 -21
| @@ -31,6 +31,18 @@ import ( | |||
| 31 | 31 | // reference that cannot be resolved is not an error of the call: its answer | |
| 32 | 32 | // carries GitHub's message. | |
| 33 | 33 | func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) { | |
| 34 | + | out, err := c.resolveActions(ctx, secret, jobID, uses) | |
| 35 | + | if err != nil && ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) { | |
| 36 | + | // Our own deadline, not the caller's: github.com (or the disk) was too | |
| 37 | + | // slow. The runner may retry, or fail the job. | |
| 38 | + | return nil, fmt.Errorf("%w: resolving the actions took more than %s", domain.ErrUnavailable, c.cfg.ResolveTimeout) | |
| 39 | + | } | |
| 40 | + | return out, err | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) { | |
| 44 | + | ctx, cancel := context.WithTimeout(ctx, c.cfg.ResolveTimeout) | |
| 45 | + | defer cancel() | |
| 34 | 46 | j, err := c.jobFor(ctx, secret, jobID) | |
| 35 | 47 | if err != nil { | |
| 36 | 48 | return nil, err | |
| @@ -52,11 +64,17 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st | |||
| 52 | 64 | parsed[i] = parseActionRef(u) | |
| 53 | 65 | } | |
| 54 | 66 | ||
| 67 | + | src, err := c.cfg.Store.GetRunSource(ctx, j.RunID) | |
| 68 | + | if err != nil { | |
| 69 | + | return nil, err | |
| 70 | + | } | |
| 71 | + | by := c.onBehalfOf(src) | |
| 72 | + | ||
| 55 | 73 | // Resolve what the attempt has not resolved yet, outside the lock of the | |
| 56 | 74 | // run: a ls-remote takes seconds and must not hold up the job's calls. | |
| 57 | 75 | found := map[string]domain.CIActionResolution{} | |
| 58 | 76 | fails := map[string]string{} | |
| 59 | - | remote := newRemoteRefs(c.cfg.Remote) | |
| 77 | + | remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots) | |
| 60 | 78 | for _, p := range parsed { | |
| 61 | 79 | if p.err != "" { | |
| 62 | 80 | continue | |
| @@ -73,7 +91,7 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st | |||
| 73 | 91 | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 74 | 92 | return nil, err | |
| 75 | 93 | } | |
| 76 | - | res, msg, err := c.resolveAction(ctx, j, p, remote) | |
| 94 | + | res, msg, err := c.resolveAction(ctx, j, p, remote, by) | |
| 77 | 95 | if err != nil { | |
| 78 | 96 | return nil, err | |
| 79 | 97 | } | |
| @@ -120,7 +138,7 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st | |||
| 120 | 138 | if !ok { | |
| 121 | 139 | continue | |
| 122 | 140 | } | |
| 123 | - | ra, err := c.answer(ctx, rs, own, p, res) | |
| 141 | + | ra, err := c.answer(ctx, rs, own, p, res, by) | |
| 124 | 142 | if err != nil { | |
| 125 | 143 | return err | |
| 126 | 144 | } | |
| @@ -145,9 +163,12 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st | |||
| 145 | 163 | const maxActionRefs = 100 | |
| 146 | 164 | ||
| 147 | 165 | // answer builds the answer for a resolved reference. The archive token of an | |
| 148 | - | // ermite action is the one the job already holds for that repository, else a | |
| 149 | - | // new one, written with the next flush. | |
| 150 | - | func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution) (ports.ResolvedAction, error) { | |
| 166 | + | // ermite action is bound to the commit of the answer: the one the job already | |
| 167 | + | // holds for it, else a new one, written with the next flush. Whether the pusher | |
| 168 | + | // may read the repository is asked again at each answer, not only when the | |
| 169 | + | // commit was resolved: a token revoked since, or a repository made private, | |
| 170 | + | // stops the answers. | |
| 171 | + | func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution, by pusherAccess) (ports.ResolvedAction, error) { | |
| 151 | 172 | ra := ports.ResolvedAction{ | |
| 152 | 173 | Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source, | |
| 153 | 174 | } | |
| @@ -162,37 +183,88 @@ func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p action | |||
| 162 | 183 | if err != nil { | |
| 163 | 184 | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | |
| 164 | 185 | } | |
| 165 | - | if _, err := c.cfg.Meta.Get(ctx, name); errors.Is(err, domain.ErrNotFound) { | |
| 186 | + | repo, err := c.cfg.Meta.Get(ctx, name) | |
| 187 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 166 | 188 | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | |
| 167 | 189 | } else if err != nil { | |
| 168 | 190 | return ports.ResolvedAction{}, err | |
| 169 | 191 | } | |
| 192 | + | if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil { | |
| 193 | + | return ports.ResolvedAction{}, err | |
| 194 | + | } else if !ok { | |
| 195 | + | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | |
| 196 | + | } | |
| 170 | 197 | ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA | |
| 171 | - | ra.Token = c.archiveToken(rs, j, name) | |
| 198 | + | ra.Token = c.archiveToken(rs, j, name, res.ResolvedSHA) | |
| 172 | 199 | } | |
| 173 | 200 | return ra, nil | |
| 174 | 201 | } | |
| 175 | 202 | ||
| 176 | - | // archiveToken returns the secret of the read token of the job for the | |
| 177 | - | // archives of repo, issuing it when the job has none. | |
| 178 | - | func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName) string { | |
| 179 | - | ref := jobRef(*j) | |
| 203 | + | // archiveToken returns the secret of the token of the job for the archive of | |
| 204 | + | // the commit sha of repo, issuing it when the job has none. The token downloads | |
| 205 | + | // that archive and nothing else (domain.RepoTokenPrincipal). | |
| 206 | + | func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, sha string) string { | |
| 207 | + | ref, key := jobRef(*j), repo.String()+"@"+sha | |
| 180 | 208 | c.mu.Lock() | |
| 181 | - | tok, ok := c.archiveTokens[ref][repo] | |
| 209 | + | tok, ok := c.archiveTokens[ref][key] | |
| 182 | 210 | c.mu.Unlock() | |
| 183 | 211 | if ok { | |
| 184 | 212 | return tok | |
| 185 | 213 | } | |
| 186 | - | tok = rs.issueRepoTokenFor(j, repo, true) | |
| 214 | + | tok = rs.issueRepoTokenFor(j, repo, sha) | |
| 187 | 215 | c.mu.Lock() | |
| 188 | 216 | if c.archiveTokens[ref] == nil { | |
| 189 | - | c.archiveTokens[ref] = map[domain.RepoName]string{} | |
| 217 | + | c.archiveTokens[ref] = map[string]string{} | |
| 190 | 218 | } | |
| 191 | - | c.archiveTokens[ref][repo] = tok | |
| 219 | + | c.archiveTokens[ref][key] = tok | |
| 192 | 220 | c.mu.Unlock() | |
| 193 | 221 | return tok | |
| 194 | 222 | } | |
| 195 | 223 | ||
| 224 | + | // pusherAccess is who the actions of a run are resolved on behalf of: the | |
| 225 | + | // pusher recorded with the run. | |
| 226 | + | type pusherAccess struct{ pusher domain.CIPusher } | |
| 227 | + | ||
| 228 | + | func (c *CI) onBehalfOf(src domain.CIRunSource) pusherAccess { return pusherAccess{src.Pusher} } | |
| 229 | + | ||
| 230 | + | // pusherCanRead tells whether the pusher of a run may read repo, with the rule | |
| 231 | + | // of the forge (canRead) applied to the principal the pusher is now: the root | |
| 232 | + | // token and the owner's session read everything; a stored token is looked up | |
| 233 | + | // again, so a token revoked or expired since reads nothing, and its scopes and | |
| 234 | + | // repository list apply; a run that records no pusher reads what an anonymous | |
| 235 | + | // caller reads. The repository of the run itself needs no check: the job reads | |
| 236 | + | // it with its own token. | |
| 237 | + | func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.RepoName, repo domain.Repo) (bool, error) { | |
| 238 | + | if repo.Name == runRepo { | |
| 239 | + | return true, nil | |
| 240 | + | } | |
| 241 | + | var p domain.Principal | |
| 242 | + | switch by.pusher.Kind { | |
| 243 | + | case domain.CIPusherRoot: | |
| 244 | + | p = domain.Root() | |
| 245 | + | case domain.CIPusherOwner: | |
| 246 | + | p = domain.Owner() | |
| 247 | + | case domain.CIPusherToken: | |
| 248 | + | if c.cfg.Tokens == nil { | |
| 249 | + | return false, nil | |
| 250 | + | } | |
| 251 | + | tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID) | |
| 252 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 253 | + | return false, nil | |
| 254 | + | } | |
| 255 | + | if err != nil { | |
| 256 | + | return false, err | |
| 257 | + | } | |
| 258 | + | if tok.Expired(c.now()) { | |
| 259 | + | return false, nil | |
| 260 | + | } | |
| 261 | + | p = domain.TokenPrincipal(tok) | |
| 262 | + | default: | |
| 263 | + | p = domain.Anonymous() | |
| 264 | + | } | |
| 265 | + | return canRead(p, repo), nil | |
| 266 | + | } | |
| 267 | + | ||
| 196 | 268 | // actionRef is a parsed "uses": owner/repo[/path]@ref. | |
| 197 | 269 | type actionRef struct { | |
| 198 | 270 | uses, owner, repo, path, ref string | |
| @@ -221,11 +293,33 @@ func parseActionRef(uses string) actionRef { | |||
| 221 | 293 | return a | |
| 222 | 294 | } | |
| 223 | 295 | } | |
| 296 | + | if !validActionName(segs[0]) || !validActionName(segs[1]) { | |
| 297 | + | a.err = errActionFormat(uses) | |
| 298 | + | return a | |
| 299 | + | } | |
| 224 | 300 | a.owner, a.repo, a.ref = segs[0], segs[1], ref | |
| 225 | 301 | a.path = strings.Join(segs[2:], "/") | |
| 226 | 302 | return a | |
| 227 | 303 | } | |
| 228 | 304 | ||
| 305 | + | // validActionName reports whether s can be an owner or a repository name on | |
| 306 | + | // GitHub: letters, digits, '.', '_' and '-', 1 to 100 characters, not made of | |
| 307 | + | // dots only ("." and ".." are path elements). Every source takes the same names, so that nothing but a name reaches a | |
| 308 | + | // URL, a path or a command. | |
| 309 | + | func validActionName(s string) bool { | |
| 310 | + | if s == "" || len(s) > 100 || strings.Trim(s, ".") == "" { | |
| 311 | + | return false | |
| 312 | + | } | |
| 313 | + | for _, r := range s { | |
| 314 | + | switch { | |
| 315 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |
| 316 | + | default: | |
| 317 | + | return false | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | return true | |
| 321 | + | } | |
| 322 | + | ||
| 229 | 323 | // --- GitHub's texts --- | |
| 230 | 324 | // | |
| 231 | 325 | // All the messages a reference can fail with are here. The first is the | |
| @@ -247,11 +341,19 @@ func errUnresolvedRepo(a actionRef) string { | |||
| 247 | 341 | ||
| 248 | 342 | // resolveAction resolves one reference. msg is the message of a reference that | |
| 249 | 343 | // does not resolve; err is a failure of the call. | |
| 250 | - | func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs) (res domain.CIActionResolution, msg string, err error) { | |
| 344 | + | func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) { | |
| 251 | 345 | res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()} | |
| 252 | - | if strings.EqualFold(a.owner, c.cfg.Owner) { | |
| 346 | + | if domain.OwnerMatches(c.cfg.Owner, a.owner) { | |
| 253 | 347 | if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil { | |
| 254 | - | if _, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil { | |
| 348 | + | if repo, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil { | |
| 349 | + | // A repository the pusher may not read answers as one that does | |
| 350 | + | // not exist, before any ref is looked at, so that the texts leak | |
| 351 | + | // nothing about it. | |
| 352 | + | if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil { | |
| 353 | + | return res, "", err | |
| 354 | + | } else if !ok { | |
| 355 | + | return res, errUnresolvedRepo(a), nil | |
| 356 | + | } | |
| 255 | 357 | sha, found, err := c.resolveOnErmite(ctx, name, a.ref) | |
| 256 | 358 | if err != nil { | |
| 257 | 359 | return res, "", err | |
| @@ -333,6 +435,7 @@ func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref stri | |||
| 333 | 435 | // remoteRefs lists the refs of a remote once per repository within a call. | |
| 334 | 436 | type remoteRefs struct { | |
| 335 | 437 | remote ports.GitRemote | |
| 438 | + | slots chan struct{} | |
| 336 | 439 | seen map[string]remoteList | |
| 337 | 440 | } | |
| 338 | 441 | ||
| @@ -341,8 +444,8 @@ type remoteList struct { | |||
| 341 | 444 | err error | |
| 342 | 445 | } | |
| 343 | 446 | ||
| 344 | - | func newRemoteRefs(r ports.GitRemote) *remoteRefs { | |
| 345 | - | return &remoteRefs{remote: r, seen: map[string]remoteList{}} | |
| 447 | + | func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs { | |
| 448 | + | return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}} | |
| 346 | 449 | } | |
| 347 | 450 | ||
| 348 | 451 | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| @@ -353,7 +456,18 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo | |||
| 353 | 456 | if l, ok := r.seen[key]; ok { | |
| 354 | 457 | return l.refs, l.err | |
| 355 | 458 | } | |
| 459 | + | // At most a few ls-remote at once on the whole server, whatever the number | |
| 460 | + | // of jobs resolving: a process each, and github.com is slow to answer. | |
| 461 | + | select { | |
| 462 | + | case r.slots <- struct{}{}: | |
| 463 | + | defer func() { <-r.slots }() | |
| 464 | + | case <-ctx.Done(): | |
| 465 | + | return nil, ctx.Err() | |
| 466 | + | } | |
| 356 | 467 | refs, err := r.remote.ListRefs(ctx, owner, repo) | |
| 468 | + | if ctx.Err() != nil { | |
| 469 | + | return nil, ctx.Err() | |
| 470 | + | } | |
| 357 | 471 | r.seen[key] = remoteList{refs, err} | |
| 358 | 472 | return refs, err | |
| 359 | 473 | } | |
| @@ -31,6 +31,18 @@ import ( | |||||
| 31 | // reference that cannot be resolved is not an error of the call: its answer | 31 | // reference that cannot be resolved is not an error of the call: its answer | ||
| 32 | // carries GitHub's message. | 32 | // carries GitHub's message. | ||
| 33 | func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) { | 33 | func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) { | ||
| 34 | + | out, err := c.resolveActions(ctx, secret, jobID, uses) | |||
| 35 | + | if err != nil && ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) { | |||
| 36 | + | // Our own deadline, not the caller's: github.com (or the disk) was too | |||
| 37 | + | // slow. The runner may retry, or fail the job. | |||
| 38 | + | return nil, fmt.Errorf("%w: resolving the actions took more than %s", domain.ErrUnavailable, c.cfg.ResolveTimeout) | |||
| 39 | + | } | |||
| 40 | + | return out, err | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) { | |||
| 44 | + | ctx, cancel := context.WithTimeout(ctx, c.cfg.ResolveTimeout) | |||
| 45 | + | defer cancel() | |||
| 34 | j, err := c.jobFor(ctx, secret, jobID) | 46 | j, err := c.jobFor(ctx, secret, jobID) | ||
| 35 | if err != nil { | 47 | if err != nil { | ||
| 36 | return nil, err | 48 | return nil, err | ||
| @@ -52,11 +64,17 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st | |||||
| 52 | parsed[i] = parseActionRef(u) | 64 | parsed[i] = parseActionRef(u) | ||
| 53 | } | 65 | } | ||
| 54 | 66 | ||||
| 67 | + | src, err := c.cfg.Store.GetRunSource(ctx, j.RunID) | |||
| 68 | + | if err != nil { | |||
| 69 | + | return nil, err | |||
| 70 | + | } | |||
| 71 | + | by := c.onBehalfOf(src) | |||
| 72 | + | ||||
| 55 | // Resolve what the attempt has not resolved yet, outside the lock of the | 73 | // Resolve what the attempt has not resolved yet, outside the lock of the | ||
| 56 | // run: a ls-remote takes seconds and must not hold up the job's calls. | 74 | // run: a ls-remote takes seconds and must not hold up the job's calls. | ||
| 57 | found := map[string]domain.CIActionResolution{} | 75 | found := map[string]domain.CIActionResolution{} | ||
| 58 | fails := map[string]string{} | 76 | fails := map[string]string{} | ||
| 59 | - | remote := newRemoteRefs(c.cfg.Remote) | 77 | + | remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots) |
| 60 | for _, p := range parsed { | 78 | for _, p := range parsed { | ||
| 61 | if p.err != "" { | 79 | if p.err != "" { | ||
| 62 | continue | 80 | continue | ||
| @@ -73,7 +91,7 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st | |||||
| 73 | } else if !errors.Is(err, domain.ErrNotFound) { | 91 | } else if !errors.Is(err, domain.ErrNotFound) { | ||
| 74 | return nil, err | 92 | return nil, err | ||
| 75 | } | 93 | } | ||
| 76 | - | res, msg, err := c.resolveAction(ctx, j, p, remote) | 94 | + | res, msg, err := c.resolveAction(ctx, j, p, remote, by) |
| 77 | if err != nil { | 95 | if err != nil { | ||
| 78 | return nil, err | 96 | return nil, err | ||
| 79 | } | 97 | } | ||
| @@ -120,7 +138,7 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st | |||||
| 120 | if !ok { | 138 | if !ok { | ||
| 121 | continue | 139 | continue | ||
| 122 | } | 140 | } | ||
| 123 | - | ra, err := c.answer(ctx, rs, own, p, res) | 141 | + | ra, err := c.answer(ctx, rs, own, p, res, by) |
| 124 | if err != nil { | 142 | if err != nil { | ||
| 125 | return err | 143 | return err | ||
| 126 | } | 144 | } | ||
| @@ -145,9 +163,12 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st | |||||
| 145 | const maxActionRefs = 100 | 163 | const maxActionRefs = 100 | ||
| 146 | 164 | ||||
| 147 | // answer builds the answer for a resolved reference. The archive token of an | 165 | // answer builds the answer for a resolved reference. The archive token of an | ||
| 148 | - | // ermite action is the one the job already holds for that repository, else a | 166 | + | // ermite action is bound to the commit of the answer: the one the job already |
| 149 | - | // new one, written with the next flush. | 167 | + | // holds for it, else a new one, written with the next flush. Whether the pusher |
| 150 | - | func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution) (ports.ResolvedAction, error) { | 168 | + | // may read the repository is asked again at each answer, not only when the |
| 169 | + | // commit was resolved: a token revoked since, or a repository made private, | |||
| 170 | + | // stops the answers. | |||
| 171 | + | func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution, by pusherAccess) (ports.ResolvedAction, error) { | |||
| 151 | ra := ports.ResolvedAction{ | 172 | ra := ports.ResolvedAction{ | ||
| 152 | Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source, | 173 | Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source, | ||
| 153 | } | 174 | } | ||
| @@ -162,37 +183,88 @@ func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p action | |||||
| 162 | if err != nil { | 183 | if err != nil { | ||
| 163 | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | 184 | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | ||
| 164 | } | 185 | } | ||
| 165 | - | if _, err := c.cfg.Meta.Get(ctx, name); errors.Is(err, domain.ErrNotFound) { | 186 | + | repo, err := c.cfg.Meta.Get(ctx, name) |
| 187 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 166 | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | 188 | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | ||
| 167 | } else if err != nil { | 189 | } else if err != nil { | ||
| 168 | return ports.ResolvedAction{}, err | 190 | return ports.ResolvedAction{}, err | ||
| 169 | } | 191 | } | ||
| 192 | + | if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil { | |||
| 193 | + | return ports.ResolvedAction{}, err | |||
| 194 | + | } else if !ok { | |||
| 195 | + | return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil | |||
| 196 | + | } | |||
| 170 | ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA | 197 | ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA | ||
| 171 | - | ra.Token = c.archiveToken(rs, j, name) | 198 | + | ra.Token = c.archiveToken(rs, j, name, res.ResolvedSHA) |
| 172 | } | 199 | } | ||
| 173 | return ra, nil | 200 | return ra, nil | ||
| 174 | } | 201 | } | ||
| 175 | 202 | ||||
| 176 | - | // archiveToken returns the secret of the read token of the job for the | 203 | + | // archiveToken returns the secret of the token of the job for the archive of |
| 177 | - | // archives of repo, issuing it when the job has none. | 204 | + | // the commit sha of repo, issuing it when the job has none. The token downloads |
| 178 | - | func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName) string { | 205 | + | // that archive and nothing else (domain.RepoTokenPrincipal). |
| 179 | - | ref := jobRef(*j) | 206 | + | func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, sha string) string { |
| 207 | + | ref, key := jobRef(*j), repo.String()+"@"+sha | |||
| 180 | c.mu.Lock() | 208 | c.mu.Lock() | ||
| 181 | - | tok, ok := c.archiveTokens[ref][repo] | 209 | + | tok, ok := c.archiveTokens[ref][key] |
| 182 | c.mu.Unlock() | 210 | c.mu.Unlock() | ||
| 183 | if ok { | 211 | if ok { | ||
| 184 | return tok | 212 | return tok | ||
| 185 | } | 213 | } | ||
| 186 | - | tok = rs.issueRepoTokenFor(j, repo, true) | 214 | + | tok = rs.issueRepoTokenFor(j, repo, sha) |
| 187 | c.mu.Lock() | 215 | c.mu.Lock() | ||
| 188 | if c.archiveTokens[ref] == nil { | 216 | if c.archiveTokens[ref] == nil { | ||
| 189 | - | c.archiveTokens[ref] = map[domain.RepoName]string{} | 217 | + | c.archiveTokens[ref] = map[string]string{} |
| 190 | } | 218 | } | ||
| 191 | - | c.archiveTokens[ref][repo] = tok | 219 | + | c.archiveTokens[ref][key] = tok |
| 192 | c.mu.Unlock() | 220 | c.mu.Unlock() | ||
| 193 | return tok | 221 | return tok | ||
| 194 | } | 222 | } | ||
| 195 | 223 | ||||
| 224 | + | // pusherAccess is who the actions of a run are resolved on behalf of: the | |||
| 225 | + | // pusher recorded with the run. | |||
| 226 | + | type pusherAccess struct{ pusher domain.CIPusher } | |||
| 227 | + | ||||
| 228 | + | func (c *CI) onBehalfOf(src domain.CIRunSource) pusherAccess { return pusherAccess{src.Pusher} } | |||
| 229 | + | ||||
| 230 | + | // pusherCanRead tells whether the pusher of a run may read repo, with the rule | |||
| 231 | + | // of the forge (canRead) applied to the principal the pusher is now: the root | |||
| 232 | + | // token and the owner's session read everything; a stored token is looked up | |||
| 233 | + | // again, so a token revoked or expired since reads nothing, and its scopes and | |||
| 234 | + | // repository list apply; a run that records no pusher reads what an anonymous | |||
| 235 | + | // caller reads. The repository of the run itself needs no check: the job reads | |||
| 236 | + | // it with its own token. | |||
| 237 | + | func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.RepoName, repo domain.Repo) (bool, error) { | |||
| 238 | + | if repo.Name == runRepo { | |||
| 239 | + | return true, nil | |||
| 240 | + | } | |||
| 241 | + | var p domain.Principal | |||
| 242 | + | switch by.pusher.Kind { | |||
| 243 | + | case domain.CIPusherRoot: | |||
| 244 | + | p = domain.Root() | |||
| 245 | + | case domain.CIPusherOwner: | |||
| 246 | + | p = domain.Owner() | |||
| 247 | + | case domain.CIPusherToken: | |||
| 248 | + | if c.cfg.Tokens == nil { | |||
| 249 | + | return false, nil | |||
| 250 | + | } | |||
| 251 | + | tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID) | |||
| 252 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 253 | + | return false, nil | |||
| 254 | + | } | |||
| 255 | + | if err != nil { | |||
| 256 | + | return false, err | |||
| 257 | + | } | |||
| 258 | + | if tok.Expired(c.now()) { | |||
| 259 | + | return false, nil | |||
| 260 | + | } | |||
| 261 | + | p = domain.TokenPrincipal(tok) | |||
| 262 | + | default: | |||
| 263 | + | p = domain.Anonymous() | |||
| 264 | + | } | |||
| 265 | + | return canRead(p, repo), nil | |||
| 266 | + | } | |||
| 267 | + | ||||
| 196 | // actionRef is a parsed "uses": owner/repo[/path]@ref. | 268 | // actionRef is a parsed "uses": owner/repo[/path]@ref. | ||
| 197 | type actionRef struct { | 269 | type actionRef struct { | ||
| 198 | uses, owner, repo, path, ref string | 270 | uses, owner, repo, path, ref string | ||
| @@ -221,11 +293,33 @@ func parseActionRef(uses string) actionRef { | |||||
| 221 | return a | 293 | return a | ||
| 222 | } | 294 | } | ||
| 223 | } | 295 | } | ||
| 296 | + | if !validActionName(segs[0]) || !validActionName(segs[1]) { | |||
| 297 | + | a.err = errActionFormat(uses) | |||
| 298 | + | return a | |||
| 299 | + | } | |||
| 224 | a.owner, a.repo, a.ref = segs[0], segs[1], ref | 300 | a.owner, a.repo, a.ref = segs[0], segs[1], ref | ||
| 225 | a.path = strings.Join(segs[2:], "/") | 301 | a.path = strings.Join(segs[2:], "/") | ||
| 226 | return a | 302 | return a | ||
| 227 | } | 303 | } | ||
| 228 | 304 | ||||
| 305 | + | // validActionName reports whether s can be an owner or a repository name on | |||
| 306 | + | // GitHub: letters, digits, '.', '_' and '-', 1 to 100 characters, not made of | |||
| 307 | + | // dots only ("." and ".." are path elements). Every source takes the same names, so that nothing but a name reaches a | |||
| 308 | + | // URL, a path or a command. | |||
| 309 | + | func validActionName(s string) bool { | |||
| 310 | + | if s == "" || len(s) > 100 || strings.Trim(s, ".") == "" { | |||
| 311 | + | return false | |||
| 312 | + | } | |||
| 313 | + | for _, r := range s { | |||
| 314 | + | switch { | |||
| 315 | + | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |||
| 316 | + | default: | |||
| 317 | + | return false | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | return true | |||
| 321 | + | } | |||
| 322 | + | ||||
| 229 | // --- GitHub's texts --- | 323 | // --- GitHub's texts --- | ||
| 230 | // | 324 | // | ||
| 231 | // All the messages a reference can fail with are here. The first is the | 325 | // All the messages a reference can fail with are here. The first is the | ||
| @@ -247,11 +341,19 @@ func errUnresolvedRepo(a actionRef) string { | |||||
| 247 | 341 | ||||
| 248 | // resolveAction resolves one reference. msg is the message of a reference that | 342 | // resolveAction resolves one reference. msg is the message of a reference that | ||
| 249 | // does not resolve; err is a failure of the call. | 343 | // does not resolve; err is a failure of the call. | ||
| 250 | - | func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs) (res domain.CIActionResolution, msg string, err error) { | 344 | + | func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) { |
| 251 | res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()} | 345 | res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()} | ||
| 252 | - | if strings.EqualFold(a.owner, c.cfg.Owner) { | 346 | + | if domain.OwnerMatches(c.cfg.Owner, a.owner) { |
| 253 | if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil { | 347 | if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil { | ||
| 254 | - | if _, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil { | 348 | + | if repo, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil { |
| 349 | + | // A repository the pusher may not read answers as one that does | |||
| 350 | + | // not exist, before any ref is looked at, so that the texts leak | |||
| 351 | + | // nothing about it. | |||
| 352 | + | if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil { | |||
| 353 | + | return res, "", err | |||
| 354 | + | } else if !ok { | |||
| 355 | + | return res, errUnresolvedRepo(a), nil | |||
| 356 | + | } | |||
| 255 | sha, found, err := c.resolveOnErmite(ctx, name, a.ref) | 357 | sha, found, err := c.resolveOnErmite(ctx, name, a.ref) | ||
| 256 | if err != nil { | 358 | if err != nil { | ||
| 257 | return res, "", err | 359 | return res, "", err | ||
| @@ -333,6 +435,7 @@ func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref stri | |||||
| 333 | // remoteRefs lists the refs of a remote once per repository within a call. | 435 | // remoteRefs lists the refs of a remote once per repository within a call. | ||
| 334 | type remoteRefs struct { | 436 | type remoteRefs struct { | ||
| 335 | remote ports.GitRemote | 437 | remote ports.GitRemote | ||
| 438 | + | slots chan struct{} | |||
| 336 | seen map[string]remoteList | 439 | seen map[string]remoteList | ||
| 337 | } | 440 | } | ||
| 338 | 441 | ||||
| @@ -341,8 +444,8 @@ type remoteList struct { | |||||
| 341 | err error | 444 | err error | ||
| 342 | } | 445 | } | ||
| 343 | 446 | ||||
| 344 | - | func newRemoteRefs(r ports.GitRemote) *remoteRefs { | 447 | + | func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs { |
| 345 | - | return &remoteRefs{remote: r, seen: map[string]remoteList{}} | 448 | + | return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}} |
| 346 | } | 449 | } | ||
| 347 | 450 | ||||
| 348 | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | 451 | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | ||
| @@ -353,7 +456,18 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo | |||||
| 353 | if l, ok := r.seen[key]; ok { | 456 | if l, ok := r.seen[key]; ok { | ||
| 354 | return l.refs, l.err | 457 | return l.refs, l.err | ||
| 355 | } | 458 | } | ||
| 459 | + | // At most a few ls-remote at once on the whole server, whatever the number | |||
| 460 | + | // of jobs resolving: a process each, and github.com is slow to answer. | |||
| 461 | + | select { | |||
| 462 | + | case r.slots <- struct{}{}: | |||
| 463 | + | defer func() { <-r.slots }() | |||
| 464 | + | case <-ctx.Done(): | |||
| 465 | + | return nil, ctx.Err() | |||
| 466 | + | } | |||
| 356 | refs, err := r.remote.ListRefs(ctx, owner, repo) | 467 | refs, err := r.remote.ListRefs(ctx, owner, repo) | ||
| 468 | + | if ctx.Err() != nil { | |||
| 469 | + | return nil, ctx.Err() | |||
| 470 | + | } | |||
| 357 | r.seen[key] = remoteList{refs, err} | 471 | r.seen[key] = remoteList{refs, err} | ||
| 358 | return refs, err | 472 | return refs, err | ||
| 359 | } | 473 | } | ||
M internal/usecase/ci_actions_test.go +7 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| @@ -28,6 +29,12 @@ func TestParseActionRef(t *testing.T) { | |||
| 28 | 29 | {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"}, | |
| 29 | 30 | {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"}, | |
| 30 | 31 | {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"}, | |
| 32 | + | {uses: "a/...@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/...@v1'"}, | |
| 33 | + | {uses: "../..@" + strings.Repeat("a", 40), fail: "Expected format {org}/{repo}[/path]@ref. Actual '../..@" + strings.Repeat("a", 40) + "'"}, | |
| 34 | + | {uses: "a%2f/b@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a%2f/b@v1'"}, | |
| 35 | + | {uses: "a/b?x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b?x@v1'"}, | |
| 36 | + | {uses: "a/b#x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b#x@v1'"}, | |
| 37 | + | {uses: "-a/_b.c@v1", owner: "-a", repo: "_b.c", ref: "v1"}, | |
| 31 | 38 | {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"}, | |
| 32 | 39 | } | |
| 33 | 40 | for _, tt := range tests { | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package usecase | 3 | package usecase | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "strings" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 8 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| @@ -28,6 +29,12 @@ func TestParseActionRef(t *testing.T) { | |||||
| 28 | {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"}, | 29 | {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"}, | ||
| 29 | {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"}, | 30 | {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"}, | ||
| 30 | {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"}, | 31 | {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"}, | ||
| 32 | + | {uses: "a/...@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/...@v1'"}, | |||
| 33 | + | {uses: "../..@" + strings.Repeat("a", 40), fail: "Expected format {org}/{repo}[/path]@ref. Actual '../..@" + strings.Repeat("a", 40) + "'"}, | |||
| 34 | + | {uses: "a%2f/b@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a%2f/b@v1'"}, | |||
| 35 | + | {uses: "a/b?x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b?x@v1'"}, | |||
| 36 | + | {uses: "a/b#x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b#x@v1'"}, | |||
| 37 | + | {uses: "-a/_b.c@v1", owner: "-a", repo: "_b.c", ref: "v1"}, | |||
| 31 | {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"}, | 38 | {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"}, | ||
| 32 | } | 39 | } | ||
| 33 | for _, tt := range tests { | 40 | for _, tt := range tests { | ||
M internal/usecase/ci_githubapi.go +61 -13
| @@ -17,7 +17,8 @@ import ( | |||
| 17 | 17 | // on github.api_url. The access rule is the forge's: a repository is read by | |
| 18 | 18 | // anyone when public, and with repo:read when private, which the principal of a | |
| 19 | 19 | // job repository token holds on the repository of its run (and the archive | |
| 20 | - | // tokens on theirs). | |
| 20 | + | // tokens on theirs). The holder of an archive token reads none of it but its | |
| 21 | + | // own archive. | |
| 21 | 22 | ||
| 22 | 23 | var _ ports.GitHubAPIService = (*CI)(nil) | |
| 23 | 24 | ||
| @@ -25,7 +26,7 @@ var _ ports.GitHubAPIService = (*CI)(nil) | |||
| 25 | 26 | // owner, a name that is not one and a repository the caller cannot read are all | |
| 26 | 27 | // not found. | |
| 27 | 28 | func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) { | |
| 28 | - | if !strings.EqualFold(owner, c.cfg.Owner) { | |
| 29 | + | if !domain.OwnerMatches(c.cfg.Owner, owner) { | |
| 29 | 30 | return domain.Repo{}, domain.ErrNotFound | |
| 30 | 31 | } | |
| 31 | 32 | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |
| @@ -97,25 +98,72 @@ func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name | |||
| 97 | 98 | return "sha1", nil | |
| 98 | 99 | } | |
| 99 | 100 | ||
| 100 | - | // Tarball streams the archive of a ref as `git archive` makes it, in one | |
| 101 | - | // response (GitHub answers 302 to codeload: ermite serves it itself). The files | |
| 102 | - | // are in a directory <owner>-<repo>-<7 first characters of the commit>, as | |
| 103 | - | // GitHub names it. | |
| 104 | - | func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error { | |
| 105 | - | repo, err := c.githubRepo(ctx, p, owner, name) | |
| 106 | - | if err != nil { | |
| 107 | - | return err | |
| 101 | + | // archiveTarget finds the repository and the commit of an archive request and | |
| 102 | + | // checks the access to it. A reader of the repository (the job's own token, any | |
| 103 | + | // principal with repo:read, anyone for a public one) gets any ref. The holder of | |
| 104 | + | // an archive token (domain.RepoTokenPrincipal) gets its commit of its | |
| 105 | + | // repository and nothing else: the ref may be anything that resolves to exactly | |
| 106 | + | // that commit, and every other answer is not found. | |
| 107 | + | func (c *CI) archiveTarget(ctx context.Context, p domain.Principal, owner, name, ref string) (domain.Repo, domain.ObjectID, error) { | |
| 108 | + | var repo domain.Repo | |
| 109 | + | grantRepo, grantSha, granted := p.ArchiveGrant() | |
| 110 | + | if granted { | |
| 111 | + | if !domain.OwnerMatches(c.cfg.Owner, owner) { | |
| 112 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |
| 113 | + | } | |
| 114 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |
| 115 | + | if err != nil || rn != grantRepo { | |
| 116 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |
| 117 | + | } | |
| 118 | + | if repo, err = lookupRepo(ctx, c.cfg.Meta, rn); err != nil { | |
| 119 | + | return domain.Repo{}, domain.ObjectID{}, err | |
| 120 | + | } | |
| 121 | + | } else { | |
| 122 | + | var err error | |
| 123 | + | if repo, err = c.githubRepo(ctx, p, owner, name); err != nil { | |
| 124 | + | return domain.Repo{}, domain.ObjectID{}, err | |
| 125 | + | } | |
| 108 | 126 | } | |
| 109 | 127 | rev, err := domain.ParseRevision(ref) | |
| 110 | 128 | if err != nil { | |
| 111 | - | return domain.ErrNotFound | |
| 129 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |
| 112 | 130 | } | |
| 113 | 131 | res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev) | |
| 132 | + | if err != nil { | |
| 133 | + | return domain.Repo{}, domain.ObjectID{}, err | |
| 134 | + | } | |
| 135 | + | if granted && res.CommitID.String() != grantSha { | |
| 136 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |
| 137 | + | } | |
| 138 | + | return repo, res.CommitID, nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (c *CI) archiveDir(repo domain.Repo, commit domain.ObjectID) string { | |
| 142 | + | return c.cfg.Owner + "-" + repo.Name.String() + "-" + commit.String()[:7] | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // TarballName answers a HEAD of the archive: the access and the ref are | |
| 146 | + | // checked as for the archive, and the name of the file is returned, without | |
| 147 | + | // running git archive. | |
| 148 | + | func (c *CI) TarballName(ctx context.Context, p domain.Principal, owner, name, ref string) (string, error) { | |
| 149 | + | repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref) | |
| 150 | + | if err != nil { | |
| 151 | + | return "", err | |
| 152 | + | } | |
| 153 | + | return c.archiveDir(repo, commit) + ".tar.gz", nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Tarball streams the archive of a ref as `git archive` makes it, in one | |
| 157 | + | // response (GitHub answers 302 to codeload: ermite serves it itself). The files | |
| 158 | + | // are in a directory <owner>-<repo>-<7 first characters of the commit>, as | |
| 159 | + | // GitHub names it. | |
| 160 | + | func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error { | |
| 161 | + | repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref) | |
| 114 | 162 | if err != nil { | |
| 115 | 163 | return err | |
| 116 | 164 | } | |
| 117 | - | dir := c.cfg.Owner + "-" + repo.Name.String() + "-" + shortID(res.CommitID.String())[:7] | |
| 118 | - | return c.cfg.Archive.Archive(ctx, repo.Name, res.CommitID, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }}) | |
| 165 | + | dir := c.archiveDir(repo, commit) | |
| 166 | + | return c.cfg.Archive.Archive(ctx, repo.Name, commit, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }}) | |
| 119 | 167 | } | |
| 120 | 168 | ||
| 121 | 169 | // lazyWriter calls begin at the first write, so that nothing is sent before git | |
| @@ -17,7 +17,8 @@ import ( | |||||
| 17 | // on github.api_url. The access rule is the forge's: a repository is read by | 17 | // on github.api_url. The access rule is the forge's: a repository is read by | ||
| 18 | // anyone when public, and with repo:read when private, which the principal of a | 18 | // anyone when public, and with repo:read when private, which the principal of a | ||
| 19 | // job repository token holds on the repository of its run (and the archive | 19 | // job repository token holds on the repository of its run (and the archive | ||
| 20 | - | // tokens on theirs). | 20 | + | // tokens on theirs). The holder of an archive token reads none of it but its |
| 21 | + | // own archive. | |||
| 21 | 22 | ||||
| 22 | var _ ports.GitHubAPIService = (*CI)(nil) | 23 | var _ ports.GitHubAPIService = (*CI)(nil) | ||
| 23 | 24 | ||||
| @@ -25,7 +26,7 @@ var _ ports.GitHubAPIService = (*CI)(nil) | |||||
| 25 | // owner, a name that is not one and a repository the caller cannot read are all | 26 | // owner, a name that is not one and a repository the caller cannot read are all | ||
| 26 | // not found. | 27 | // not found. | ||
| 27 | func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) { | 28 | func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) { | ||
| 28 | - | if !strings.EqualFold(owner, c.cfg.Owner) { | 29 | + | if !domain.OwnerMatches(c.cfg.Owner, owner) { |
| 29 | return domain.Repo{}, domain.ErrNotFound | 30 | return domain.Repo{}, domain.ErrNotFound | ||
| 30 | } | 31 | } | ||
| 31 | rn, err := domain.ParseRepoName(strings.ToLower(name)) | 32 | rn, err := domain.ParseRepoName(strings.ToLower(name)) | ||
| @@ -97,25 +98,72 @@ func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name | |||||
| 97 | return "sha1", nil | 98 | return "sha1", nil | ||
| 98 | } | 99 | } | ||
| 99 | 100 | ||||
| 100 | - | // Tarball streams the archive of a ref as `git archive` makes it, in one | 101 | + | // archiveTarget finds the repository and the commit of an archive request and |
| 101 | - | // response (GitHub answers 302 to codeload: ermite serves it itself). The files | 102 | + | // checks the access to it. A reader of the repository (the job's own token, any |
| 102 | - | // are in a directory <owner>-<repo>-<7 first characters of the commit>, as | 103 | + | // principal with repo:read, anyone for a public one) gets any ref. The holder of |
| 103 | - | // GitHub names it. | 104 | + | // an archive token (domain.RepoTokenPrincipal) gets its commit of its |
| 104 | - | func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error { | 105 | + | // repository and nothing else: the ref may be anything that resolves to exactly |
| 105 | - | repo, err := c.githubRepo(ctx, p, owner, name) | 106 | + | // that commit, and every other answer is not found. |
| 106 | - | if err != nil { | 107 | + | func (c *CI) archiveTarget(ctx context.Context, p domain.Principal, owner, name, ref string) (domain.Repo, domain.ObjectID, error) { |
| 107 | - | return err | 108 | + | var repo domain.Repo |
| 109 | + | grantRepo, grantSha, granted := p.ArchiveGrant() | |||
| 110 | + | if granted { | |||
| 111 | + | if !domain.OwnerMatches(c.cfg.Owner, owner) { | |||
| 112 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |||
| 113 | + | } | |||
| 114 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |||
| 115 | + | if err != nil || rn != grantRepo { | |||
| 116 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |||
| 117 | + | } | |||
| 118 | + | if repo, err = lookupRepo(ctx, c.cfg.Meta, rn); err != nil { | |||
| 119 | + | return domain.Repo{}, domain.ObjectID{}, err | |||
| 120 | + | } | |||
| 121 | + | } else { | |||
| 122 | + | var err error | |||
| 123 | + | if repo, err = c.githubRepo(ctx, p, owner, name); err != nil { | |||
| 124 | + | return domain.Repo{}, domain.ObjectID{}, err | |||
| 125 | + | } | |||
| 108 | } | 126 | } | ||
| 109 | rev, err := domain.ParseRevision(ref) | 127 | rev, err := domain.ParseRevision(ref) | ||
| 110 | if err != nil { | 128 | if err != nil { | ||
| 111 | - | return domain.ErrNotFound | 129 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound |
| 112 | } | 130 | } | ||
| 113 | res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev) | 131 | res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev) | ||
| 132 | + | if err != nil { | |||
| 133 | + | return domain.Repo{}, domain.ObjectID{}, err | |||
| 134 | + | } | |||
| 135 | + | if granted && res.CommitID.String() != grantSha { | |||
| 136 | + | return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound | |||
| 137 | + | } | |||
| 138 | + | return repo, res.CommitID, nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (c *CI) archiveDir(repo domain.Repo, commit domain.ObjectID) string { | |||
| 142 | + | return c.cfg.Owner + "-" + repo.Name.String() + "-" + commit.String()[:7] | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // TarballName answers a HEAD of the archive: the access and the ref are | |||
| 146 | + | // checked as for the archive, and the name of the file is returned, without | |||
| 147 | + | // running git archive. | |||
| 148 | + | func (c *CI) TarballName(ctx context.Context, p domain.Principal, owner, name, ref string) (string, error) { | |||
| 149 | + | repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref) | |||
| 150 | + | if err != nil { | |||
| 151 | + | return "", err | |||
| 152 | + | } | |||
| 153 | + | return c.archiveDir(repo, commit) + ".tar.gz", nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Tarball streams the archive of a ref as `git archive` makes it, in one | |||
| 157 | + | // response (GitHub answers 302 to codeload: ermite serves it itself). The files | |||
| 158 | + | // are in a directory <owner>-<repo>-<7 first characters of the commit>, as | |||
| 159 | + | // GitHub names it. | |||
| 160 | + | func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error { | |||
| 161 | + | repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref) | |||
| 114 | if err != nil { | 162 | if err != nil { | ||
| 115 | return err | 163 | return err | ||
| 116 | } | 164 | } | ||
| 117 | - | dir := c.cfg.Owner + "-" + repo.Name.String() + "-" + shortID(res.CommitID.String())[:7] | 165 | + | dir := c.archiveDir(repo, commit) |
| 118 | - | return c.cfg.Archive.Archive(ctx, repo.Name, res.CommitID, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }}) | 166 | + | return c.cfg.Archive.Archive(ctx, repo.Name, commit, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }}) |
| 119 | } | 167 | } | ||
| 120 | 168 | ||||
| 121 | // lazyWriter calls begin at the first write, so that nothing is sent before git | 169 | // lazyWriter calls begin at the first write, so that nothing is sent before git | ||
M internal/usecase/ci_repotoken.go +7 -5
| @@ -36,12 +36,14 @@ func (c *CI) repoTokenExpiry(j domain.CIJob) (expires domain.CIRepoToken) { | |||
| 36 | 36 | ||
| 37 | 37 | // issueRepoToken adds a repository token for j to the next flush and returns | |
| 38 | 38 | // its secret. j must be assigned (its AssignedAt is set). | |
| 39 | - | func (rs *runState) issueRepoToken(j *domain.CIJob, archive bool) string { | |
| 40 | - | return rs.issueRepoTokenFor(j, rs.run.Repo, archive) | |
| 39 | + | func (rs *runState) issueRepoToken(j *domain.CIJob) string { | |
| 40 | + | return rs.issueRepoTokenFor(j, rs.run.Repo, "") | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | // issueRepoTokenFor is issueRepoToken for a token that reads repo. | |
| 44 | - | func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, archive bool) string { | |
| 43 | + | // issueRepoTokenFor is issueRepoToken for a token that reads repo. A sha makes | |
| 44 | + | // it the archive token of that commit of repo, which downloads that archive and | |
| 45 | + | // nothing else. | |
| 46 | + | func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha string) string { | |
| 45 | 47 | c := rs.c | |
| 46 | 48 | now := c.now() | |
| 47 | 49 | at := *j | |
| @@ -51,7 +53,7 @@ func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, arc | |||
| 51 | 53 | secret := newToken(domain.RepoTokenPrefix) | |
| 52 | 54 | t := c.repoTokenExpiry(at) | |
| 53 | 55 | t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo | |
| 54 | - | t.RunID, t.RunAttempt, t.JobID, t.Archive, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, archive, now | |
| 56 | + | t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now | |
| 55 | 57 | rs.tokens = append(rs.tokens, t) | |
| 56 | 58 | return secret | |
| 57 | 59 | } | |
| @@ -36,12 +36,14 @@ func (c *CI) repoTokenExpiry(j domain.CIJob) (expires domain.CIRepoToken) { | |||||
| 36 | 36 | ||||
| 37 | // issueRepoToken adds a repository token for j to the next flush and returns | 37 | // issueRepoToken adds a repository token for j to the next flush and returns | ||
| 38 | // its secret. j must be assigned (its AssignedAt is set). | 38 | // its secret. j must be assigned (its AssignedAt is set). | ||
| 39 | - | func (rs *runState) issueRepoToken(j *domain.CIJob, archive bool) string { | 39 | + | func (rs *runState) issueRepoToken(j *domain.CIJob) string { |
| 40 | - | return rs.issueRepoTokenFor(j, rs.run.Repo, archive) | 40 | + | return rs.issueRepoTokenFor(j, rs.run.Repo, "") |
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | - | // issueRepoTokenFor is issueRepoToken for a token that reads repo. | 43 | + | // issueRepoTokenFor is issueRepoToken for a token that reads repo. A sha makes |
| 44 | - | func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, archive bool) string { | 44 | + | // it the archive token of that commit of repo, which downloads that archive and |
| 45 | + | // nothing else. | |||
| 46 | + | func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha string) string { | |||
| 45 | c := rs.c | 47 | c := rs.c | ||
| 46 | now := c.now() | 48 | now := c.now() | ||
| 47 | at := *j | 49 | at := *j | ||
| @@ -51,7 +53,7 @@ func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, arc | |||||
| 51 | secret := newToken(domain.RepoTokenPrefix) | 53 | secret := newToken(domain.RepoTokenPrefix) | ||
| 52 | t := c.repoTokenExpiry(at) | 54 | t := c.repoTokenExpiry(at) | ||
| 53 | t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo | 55 | t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo | ||
| 54 | - | t.RunID, t.RunAttempt, t.JobID, t.Archive, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, archive, now | 56 | + | t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now |
| 55 | rs.tokens = append(rs.tokens, t) | 57 | rs.tokens = append(rs.tokens, t) | ||
| 56 | return secret | 58 | return secret | ||
| 57 | } | 59 | } | ||
M internal/usecase/ci_runner.go +1 -1
| @@ -241,7 +241,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str | |||
| 241 | 241 | return errNoJob | |
| 242 | 242 | } | |
| 243 | 243 | token := newToken(domain.JobTokenPrefix) | |
| 244 | - | ghToken := rs.issueRepoToken(j, false) | |
| 244 | + | ghToken := rs.issueRepoToken(j) | |
| 245 | 245 | j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now() | |
| 246 | 246 | j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{} | |
| 247 | 247 | j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation | |
| @@ -241,7 +241,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str | |||||
| 241 | return errNoJob | 241 | return errNoJob | ||
| 242 | } | 242 | } | ||
| 243 | token := newToken(domain.JobTokenPrefix) | 243 | token := newToken(domain.JobTokenPrefix) | ||
| 244 | - | ghToken := rs.issueRepoToken(j, false) | 244 | + | ghToken := rs.issueRepoToken(j) |
| 245 | j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now() | 245 | j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now() | ||
| 246 | j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{} | 246 | j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{} | ||
| 247 | j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation | 247 | j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation | ||
M internal/usecase/ci_trigger.go +4 -3
| @@ -36,6 +36,7 @@ type ciTrigger struct { | |||
| 36 | 36 | repo domain.RepoName | |
| 37 | 37 | updates []domain.RefUpdate | |
| 38 | 38 | actor string | |
| 39 | + | pusher domain.CIPusher | |
| 39 | 40 | at time.Time | |
| 40 | 41 | log zerowrap.Logger | |
| 41 | 42 | } | |
| @@ -53,7 +54,7 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain. | |||
| 53 | 54 | if len(kept) == 0 { | |
| 54 | 55 | return | |
| 55 | 56 | } | |
| 56 | - | t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)} | |
| 57 | + | t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), pusher: domain.PusherOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)} | |
| 57 | 58 | c.mu.Lock() | |
| 58 | 59 | closed := c.closed | |
| 59 | 60 | c.mu.Unlock() | |
| @@ -384,7 +385,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f | |||
| 384 | 385 | CreatedAt: now, StartedAt: now, CompletedAt: now, | |
| 385 | 386 | } | |
| 386 | 387 | return ports.CIRunDraft{ | |
| 387 | - | Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"}, | |
| 388 | + | Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}", Pusher: t.pusher}, | |
| 388 | 389 | Checks: []domain.CICheck{{ | |
| 389 | 390 | ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted, | |
| 390 | 391 | Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now, | |
| @@ -436,7 +437,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push | |||
| 436 | 437 | run.Name, run.Title = runName, runName | |
| 437 | 438 | } | |
| 438 | 439 | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | |
| 439 | - | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, | |
| 440 | + | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, Pusher: t.pusher, | |
| 440 | 441 | }} | |
| 441 | 442 | for i, j := range wf.Jobs { | |
| 442 | 443 | job := domain.CIJob{ | |
| @@ -36,6 +36,7 @@ type ciTrigger struct { | |||||
| 36 | repo domain.RepoName | 36 | repo domain.RepoName | ||
| 37 | updates []domain.RefUpdate | 37 | updates []domain.RefUpdate | ||
| 38 | actor string | 38 | actor string | ||
| 39 | + | pusher domain.CIPusher | |||
| 39 | at time.Time | 40 | at time.Time | ||
| 40 | log zerowrap.Logger | 41 | log zerowrap.Logger | ||
| 41 | } | 42 | } | ||
| @@ -53,7 +54,7 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain. | |||||
| 53 | if len(kept) == 0 { | 54 | if len(kept) == 0 { | ||
| 54 | return | 55 | return | ||
| 55 | } | 56 | } | ||
| 56 | - | t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)} | 57 | + | t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), pusher: domain.PusherOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)} |
| 57 | c.mu.Lock() | 58 | c.mu.Lock() | ||
| 58 | closed := c.closed | 59 | closed := c.closed | ||
| 59 | c.mu.Unlock() | 60 | c.mu.Unlock() | ||
| @@ -384,7 +385,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f | |||||
| 384 | CreatedAt: now, StartedAt: now, CompletedAt: now, | 385 | CreatedAt: now, StartedAt: now, CompletedAt: now, | ||
| 385 | } | 386 | } | ||
| 386 | return ports.CIRunDraft{ | 387 | return ports.CIRunDraft{ | ||
| 387 | - | Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"}, | 388 | + | Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}", Pusher: t.pusher}, |
| 388 | Checks: []domain.CICheck{{ | 389 | Checks: []domain.CICheck{{ | ||
| 389 | ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted, | 390 | ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted, | ||
| 390 | Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now, | 391 | Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now, | ||
| @@ -436,7 +437,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push | |||||
| 436 | run.Name, run.Title = runName, runName | 437 | run.Name, run.Title = runName, runName | ||
| 437 | } | 438 | } | ||
| 438 | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | 439 | d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{ | ||
| 439 | - | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, | 440 | + | Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, Pusher: t.pusher, |
| 440 | }} | 441 | }} | ||
| 441 | for i, j := range wf.Jobs { | 442 | for i, j := range wf.Jobs { | ||
| 442 | job := domain.CIJob{ | 443 | job := domain.CIJob{ | ||
M proto/ermite/storage/ci.proto +11 -3
| @@ -112,6 +112,11 @@ message CIRunSource { | |||
| 112 | 112 | // Repository of the run: the key of the run record is run/<repo>/<id>, and | |
| 113 | 113 | // the runner side knows a run by its id only. | |
| 114 | 114 | string repo = 3; | |
| 115 | + | // Who pushed the commit: "root", "owner", "token" or empty (a run stored | |
| 116 | + | // before it was recorded, or pushed by nobody). pusher_token_id is the ID of | |
| 117 | + | // the stored token for "token". What a token may read is looked up live. | |
| 118 | + | string pusher_kind = 5; | |
| 119 | + | string pusher_token_id = 6; | |
| 115 | 120 | } | |
| 116 | 121 | ||
| 117 | 122 | // CIStep is a step of a job. | |
| @@ -223,9 +228,10 @@ message CIRunner { | |||
| 223 | 228 | ||
| 224 | 229 | // CIRepoToken is a job repository token (the "github.token" of a job, or the | |
| 225 | 230 | // token of one action archive), stored under "ghtoken/<id>". The secret is | |
| 226 | - | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and | |
| 227 | - | // "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists | |
| 228 | - | // the tokens of a job so that they are revoked together. | |
| 231 | + | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, | |
| 232 | + | // "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a | |
| 233 | + | // job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the | |
| 234 | + | // tokens that read a repository, so that they go with it. | |
| 229 | 235 | message CIRepoToken { | |
| 230 | 236 | string id = 1; | |
| 231 | 237 | bytes hash = 2; | |
| @@ -239,6 +245,8 @@ message CIRepoToken { | |||
| 239 | 245 | bool archive = 7; | |
| 240 | 246 | google.protobuf.Timestamp expires_at = 8; | |
| 241 | 247 | google.protobuf.Timestamp created_at = 9; | |
| 248 | + | // The commit an archive token downloads. | |
| 249 | + | string sha = 10; | |
| 242 | 250 | } | |
| 243 | 251 | ||
| 244 | 252 | // CIActionResolution is the resolution of an action reference for a run | |
| @@ -112,6 +112,11 @@ message CIRunSource { | |||||
| 112 | // Repository of the run: the key of the run record is run/<repo>/<id>, and | 112 | // Repository of the run: the key of the run record is run/<repo>/<id>, and | ||
| 113 | // the runner side knows a run by its id only. | 113 | // the runner side knows a run by its id only. | ||
| 114 | string repo = 3; | 114 | string repo = 3; | ||
| 115 | + | // Who pushed the commit: "root", "owner", "token" or empty (a run stored | |||
| 116 | + | // before it was recorded, or pushed by nobody). pusher_token_id is the ID of | |||
| 117 | + | // the stored token for "token". What a token may read is looked up live. | |||
| 118 | + | string pusher_kind = 5; | |||
| 119 | + | string pusher_token_id = 6; | |||
| 115 | } | 120 | } | ||
| 116 | 121 | ||||
| 117 | // CIStep is a step of a job. | 122 | // CIStep is a step of a job. | ||
| @@ -223,9 +228,10 @@ message CIRunner { | |||||
| 223 | 228 | ||||
| 224 | // CIRepoToken is a job repository token (the "github.token" of a job, or the | 229 | // CIRepoToken is a job repository token (the "github.token" of a job, or the | ||
| 225 | // token of one action archive), stored under "ghtoken/<id>". The secret is | 230 | // token of one action archive), stored under "ghtoken/<id>". The secret is | ||
| 226 | - | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and | 231 | + | // never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, |
| 227 | - | // "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists | 232 | + | // "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a |
| 228 | - | // the tokens of a job so that they are revoked together. | 233 | + | // job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the |
| 234 | + | // tokens that read a repository, so that they go with it. | |||
| 229 | message CIRepoToken { | 235 | message CIRepoToken { | ||
| 230 | string id = 1; | 236 | string id = 1; | ||
| 231 | bytes hash = 2; | 237 | bytes hash = 2; | ||
| @@ -239,6 +245,8 @@ message CIRepoToken { | |||||
| 239 | bool archive = 7; | 245 | bool archive = 7; | ||
| 240 | google.protobuf.Timestamp expires_at = 8; | 246 | google.protobuf.Timestamp expires_at = 8; | ||
| 241 | google.protobuf.Timestamp created_at = 9; | 247 | google.protobuf.Timestamp created_at = 9; | ||
| 248 | + | // The commit an archive token downloads. | |||
| 249 | + | string sha = 10; | |||
| 242 | } | 250 | } | ||
| 243 | 251 | ||||
| 244 | // CIActionResolution is the resolution of an action reference for a run | 252 | // CIActionResolution is the resolution of an action reference for a run | ||