diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1a948..9b24b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,11 @@ jobs: if: steps.template.outputs.public == 'true' run: TOUCHMARK_E2E_TEMPLATE="$PWD/.ci/template" go test -count=1 -v -run '^TestTemplatePipelines$' ./internal/e2e/bitbucket/ + # Skips while the published template has no azure-pipelines.yml. + - name: go test -run TestTemplateAzurePipelines + if: steps.template.outputs.public == 'true' + run: TOUCHMARK_E2E_TEMPLATE="$PWD/.ci/template" go test -count=1 -v -run '^TestTemplateAzurePipelines$' ./internal/e2e/azure/ + release-snapshot: # The release without publishing anything (docs/project/release.md): # the scripts of the release and of the Action by their tests; diff --git a/README.md b/README.md index 4d33392..486dcae 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,10 @@ Three ways in, one version number: - **Targets as you name them** — a repository, an organisation or a group by path or by web URL, narrowed by topics and path globs (`acme/svc-*`), minus `exclude` globs (`corp:platform/legacy/**`) -- **Four platforms, one hub** — GitHub (github.com, GHE.com, Enterprise Server), GitLab - (gitlab.com and self-managed), Gitea and Forgejo; one hub can deliver to several at once +- **Six platforms, one hub** — GitHub (github.com, GHE.com, Enterprise Server), GitLab + (gitlab.com and self-managed), Gitea, Forgejo, Bitbucket Cloud and Azure DevOps Services + (the last two built from their documentation, not yet run on a live account); one hub can + deliver to several at once, and its own CI can be any of them - **Declines remembered** — content in a pull request closed without merging is not proposed again; reopen it, or tick a box in its description, to change your mind - **A plan before delivery** — `plan` in a hub pull request reports what every target would diff --git a/docs/agents.md b/docs/agents.md index bdff59d..129e496 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -47,9 +47,9 @@ no lock file, no header, no marker in their files. It never checks a target out and never runs a script, hook, filter or LFS from the hub or a target. It delivers nothing to a repository without the opt-in file, unless `targets.yml` subscribes it (`opt_in: assumed`). There is no pull mode (targets do not -fetch from the hub), no remote pack sources, no `setup` for Bitbucket (a hub on Bitbucket Cloud -runs in Bitbucket Pipelines, set up by hand), and no hub on Azure Pipelines: Azure DevOps is a -provider a hub elsewhere delivers to. +fetch from the hub), no remote pack sources, and no `setup` for Bitbucket or Azure DevOps (a +hub on Bitbucket Cloud runs in Bitbucket Pipelines, one in Azure Repos in Azure Pipelines, both +set up by hand). ## Mental model diff --git a/docs/concepts/security.md b/docs/concepts/security.md index c9f47e3..fa61f35 100644 --- a/docs/concepts/security.md +++ b/docs/concepts/security.md @@ -60,12 +60,22 @@ their own workflow instead of running yours. So the probe runs where an attacker not show them, so under `platform` `distribute` and `doctor` refuse to run unless `security.reason` states them. Without Premium use `none` with a reason, or `external`. See [A hub on Bitbucket Cloud](../getting-started/bitbucket.md). +- **Azure Pipelines:** `touchmark probe` runs in a stage of its own at the start of every + run, without the variable group, with the writer's variable mapped, and fails if it + sees a write key: a pipeline variable reaches every branch's runs, pull request builds + included. The variable group `touchmark-distribute` reaches any stage that links it, in + any branch's `azure-pipelines.yml`, unless its Branch control check admits the default + branch alone; a job's token is not known to read that check, so under `platform` + `distribute` and `doctor` refuse to run unless `security.reason` states it, and + `doctor --hub-token` verifies it. See + [A hub on Azure DevOps](../getting-started/azure-devops.md). **The guards** of `distribute` (and of `doctor` in CI), each exit 2 with nothing written: the run is not on the default branch, or is a pull request event; on GitLab the ref is not protected or the job is not in the environment `touchmark-distribute`; on Bitbucket the step has no `deployment: touchmark-distribute`, the default branch could not be read, -or `platform` comes without the statement in `security.reason`; the probe saw +or `platform` comes without the statement in `security.reason`; on Azure Pipelines the +same, for a deployment job to the environment `touchmark-distribute`; the probe saw a write key or did not run; an operation flag was given in CI; git is older than 2.45; the write key belongs to another account than `hub.yml`'s `writer`. `plan` in CI exits 2 when it sees any write key. A run on a commit that is no longer the tip of the default diff --git a/docs/getting-started/azure-devops.md b/docs/getting-started/azure-devops.md new file mode 100644 index 0000000..b13fb67 --- /dev/null +++ b/docs/getting-started/azure-devops.md @@ -0,0 +1,224 @@ +# A hub on Azure DevOps + +A hub in Azure Repos of Azure DevOps Services, whose CI is Azure Pipelines: the template's +`azure-pipelines.yml` checks every pull request to the hub and delivers from the default +branch. Azure DevOps Server is not supported. A hub on any other platform can also +deliver to Azure DevOps as one of its providers: see +[Azure DevOps](../guide/providers.md#azure-devops). + +!!! warning "Not verified on a live Azure DevOps yet" + The Azure Pipelines support is built from Microsoft's documentation and tested against + a stand-in of its API, not on a live organization. The points a first live run must + confirm are listed under [Assumed, not yet seen live](#assumed-not-yet-seen-live). + +`touchmark setup` has nothing for Azure DevOps yet: the steps below are by hand. + +## Where the write key can live + +Azure Pipelines gives a stage the variables of a variable group only when the stage links +it, and checks the group's **approvals and checks** before the stage starts. But any +branch's copy of `azure-pipelines.yml` can link the group too, so the write key stays on +the default branch only when a **Branch control** check on the group admits the default +branch alone: the stage of any other branch then fails its checks and never starts +([checks](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals)). The +check is free on every plan, but a job's token is not known to read it, so touchmark +cannot check it from the run: + +| `hub.yml` | What keeps the write key | +|---|---| +| `write_isolation: platform` and a `reason` that states the check | the variable group `touchmark-distribute` with a Branch control check that admits the default branch only; you state it, and `doctor --hub-token` verifies it | +| `write_isolation: none` and a `reason` | nothing but who may push: every branch of the hub can read the write key | +| `write_isolation: external` | an external secret store that releases the key to the default branch only | + +```yaml +# hub.yml of a hub on Azure DevOps +version: 1 +id: acme-eng +writer: 3f2a8d4e-1b6c-4f0a-9e7d-5c2b1a0f9e8d # the writer user's identity id +security: + write_isolation: platform + reason: "Azure: Branch control admits refs/heads/main only to touchmark-distribute" +``` + +Under `platform` without a `reason`, which is how the template ships, `distribute` and +`doctor` refuse to run on Azure Pipelines (exit 2). In every run the `probe` stage fails +when it sees a write key in a variable of the pipeline itself, which every branch's runs +get. + +## Steps + +1. **Create the hub.** In your project choose *Repos → Import repository*, and give the + URL of [the template](https://github.com/bedrock-python/engineering-assets-template), + `https://github.com/bedrock-python/engineering-assets-template.git`. Then choose + *Pipelines → New pipeline → Azure Repos Git*, the hub, *Existing Azure Pipelines YAML + file* and `/azure-pipelines.yml`, and save the pipeline without running it. +2. **Create two bot users** in the organization (with Basic access), each with a personal + access token for this organization only: + - the *reader*, with read access to your target repositories and the scope + *Code (read)*; + - the *writer*, with *Contribute*, *Create branch* and *Contribute to pull requests* + on the targets only (not administration, and no access to the hub), and the scope + *Code (read & write)*. + Tokens expire: note when, and renew them before. See + [Azure DevOps](../guide/providers.md#azure-devops) for the details. +3. **Store the keys**, all secret: + - the pipeline's variable `TOUCHMARK_READ_TOKEN`, the reader's token (*Edit → + Variables*, *Keep this value secret*; leave *Let users override this value when + running this pipeline* off); + - the variable group `touchmark-distribute` (*Pipelines → Library*) with the writer's + token as `TOUCHMARK_WRITE_TOKEN`, and nowhere else: not as a variable of the + pipeline. Under the group's *Pipeline permissions*, allow the hub's pipeline alone; + under *Approvals and checks*, add **Branch control** with *Allowed branches* + `refs/heads/main` (your default branch), *Verify branch protection* on, and *Allow + unknown status* off. + The hub delivers to Azure DevOps with these one-provider names; with `providers` in + `hub.yml` they carry the provider's id (`TOUCHMARK_ADO_WRITE_TOKEN`), and the step + template `.azure-pipelines/touchmark.yml` must map those names instead. +4. **Create the environment** `touchmark-distribute` (*Pipelines → Environments*, with no + resources). `distribute` and `doctor` run as deployment jobs to it, and touchmark + refuses them in any other job. Add its checks **Exclusive lock**, so that two runs + never overlap, and the same **Branch control**. +5. **Protect the hub.** Under *Project settings → Repositories → the hub → Policies*, on + the default branch: + - add **Build validation** with the hub's pipeline, required. That policy is what runs + the pipeline for pull requests: Azure Repos ignores `pr:` triggers; + - add your maintainers as automatically included reviewers, **required**, and require + a minimum number of reviewers with *Reset all approval votes* when there are new + changes and *Prohibit the most recent pusher from approving their own changes*. + A pull request build runs its own branch's YAML with the job access token, so a + policy that any vote satisfies is not enough (step 6). + Azure DevOps reads none of the template's CODEOWNERS files: delete them with the other + platforms' CI files. Under *Organization settings → Pipelines → Settings* turn on + *Limit variables that can be set at queue time*, *Limit job authorization scope to + current project* and *Protect access to repositories in YAML pipelines* + ([below](#what-the-pipeline-does)). +6. **Optionally, let the pipeline comment.** `plan` keeps its report in a comment of the + hub pull request only when ` Build Service ()`, the identity of + the job access token, may *Contribute to pull requests* on the hub (*Project settings → + Repositories → the hub → Security*). That right also lets the job vote on pull + requests, and a pull request build runs the YAML of the branch under review: grant it + only with your maintainers as required reviewers (step 5). Without it the report is + still in the run's summary and artifacts, and `plan` says the comment was not posted. +7. **Describe your hub.** In `hub.yml` set `id`, `writer` to the writer user's identity id + (`GET https://dev.azure.com//_apis/connectionData` with its token shows + it as `authenticatedUser.id`), and `security` as above. List your repositories in + `targets.yml` (`org: ` with `match: ["/*"]`, or + `repo: /`; Azure DevOps has no topics). +8. **Open a pull request** and check the `probe`, `check` and `plan` stages. The first run + asks you to permit the pipeline to use the variable group and the environment. The + report files are the run's artifacts, the run's summary shows them, and with step 6 + `plan` keeps the report in one comment of the pull request. +9. **Merge it.** The run of the default branch does `probe`, then `distribute`, which opens + a pull request in every repository that has [opted in](opt-in.md). The schedules run + `distribute` every day and `doctor` every Monday, and *Run pipeline* asks which of the + two to run. + +## What the pipeline does + +| Run | Stages | +|---|---| +| pull request to the hub (Build validation) | `probe`, then `check` and `plan --strict --comment` | +| push to `main` or `master`, the schedule "touchmark distribute", *Run pipeline* with `distribute` | `probe`, then `distribute` in the environment `touchmark-distribute`, with the variable group | +| the schedule "touchmark doctor", *Run pipeline* with `doctor` | `probe`, then `doctor`, likewise | + +- **The image.** An Azure container job needs bash, glibc and Node.js in the image, and + touchmark's image is Alpine with busybox, so the step template runs it with docker on + the agent (`ubuntu-24.04`), as the agent's user, without capabilities, with the image + pinned by digest in `.azure-pipelines/touchmark.yml` alone. The container gets `CI`, + `TF_BUILD`, the `BUILD_*`, `SYSTEM_*`, `ENVIRONMENT_*` and `TOUCHMARK_*` variables of the + step and the proxy settings, by name, and the sources directory. A self-hosted agent + needs docker. +- **The keys.** A secret variable reaches a step only when the step maps it: the step + template maps `System.AccessToken` into `SYSTEM_ACCESSTOKEN`, `TOUCHMARK_READ_TOKEN` + always, and `TOUCHMARK_WRITE_TOKEN` in the probe and the deployment jobs. A secret the + run does not define stays `$(NAME)` in the step's environment; the step leaves it out. +- **The default branch.** No variable names it, so touchmark reads it with the job access + token and refuses `distribute` and `doctor` on any other branch, or when it cannot read + it. The triggers and the stages' conditions name `main` and `master`: add your default + branch if it has another name. +- **The environment.** `distribute` and `doctor` run only in a deployment job to the + environment `touchmark-distribute` (`Environment.Name`). +- **Time.** A Microsoft-hosted job of a free private project ends after 60 minutes: the + `distribute` job has `timeoutInMinutes: 60`, and touchmark starts no target after 50 + (`--deadline 50m`); the next run picks up what is left. With paid parallel jobs, raise + both. +- **Reports.** The step template uploads `touchmark-report.md` and `touchmark-doctor.md` + to the run's summary and publishes the report files as an artifact. +- **The plan comment** (with step 6) is written with the job access token, as a new thread touchmark + closes at once, so that a policy that requires resolved comments never waits on it. It + is plain Markdown, its marker a link reference definition, + `[touchmark-plan]: # "touchmark plan: "`. touchmark learns its own identity + (`connectionData`) and edits only its own comment. +- **Variables set at queue time** reach every step of the run, also the steps that hold + the write key: `BASH_ENV` makes the step's bash run a command, `DOCKER_HOST` would send + the container, and the keys it gets, to another daemon (the step template unsets the + `DOCKER_*` variables, but bash reads `BASH_ENV` before its first line). Keep *Limit + variables that can be set at queue time* on, and the pipeline's variables not + overridable: then a run can be given only the variables the YAML declares settable, + and the template declares none. `doctor --hub-token` fails while the setting is off. + For more, add an **Approval** check on the environment `touchmark-distribute`, or limit + who may *Queue builds* on the hub's pipeline. +- **Logging commands.** The Azure agent runs any `##vso[…]` it finds in a step's output: + under Azure Pipelines touchmark breaks every `##vso[` it prints, so that nothing a + platform or a target says becomes a command. +- **The fingerprint** of the hub is `dev.azure.com/`, the id lowercase: + `dev.azure.com/0b7e5a2c-9d4f-4e1b-8a3c-6f5d2e1c0b9a`. Repository ids are unique across + organizations, so the fingerprint names no organization, and survives renames. + +## Check it + +`touchmark doctor --hub-token --hub-fp dev.azure.com/`, run from a clone of +the hub, reads where the keys live with the token in `TOUCHMARK_HUB_TOKEN`: a maintainer's +personal access token with *Code (read)*, *Build (read)* and *Variable Groups (read)*. +`GET https://dev.azure.com///_apis/git/repositories/` +shows the repository's id. A hub without `providers` takes its organization from the +clone's origin (`https://dev.azure.com//…`, or its SSH form); with +providers of several organizations, the origin picks the hub's. + +- A write key in a variable of the hub's pipelines fails. +- One in a variable group is `ok` when a Branch control check admits the default branch + alone (`refs/heads/`) and verifies its protection; it fails without such a + check, warns in another group than `touchmark-distribute`, and is unknown when the + checks could not be read. +- A group open to every pipeline of the project warns, and fails when no check verifies + branch protection: Branch control compares only the run's branch name, which another + repository's pipeline on its own default branch shares. +- A key that is not secret warns. +- The check `pipeline-settings` fails while *Limit variables that can be set at queue + time* is off, and warns while the job access token reaches other projects or + repositories. + +## Assumed, not yet seen live + +- The job access token, mapped into `SYSTEM_ACCESSTOKEN`, reads the hub repository + (default branch, refs), its project (visibility) and `connectionData` + (`authenticatedUser.id`, the build service's identity), with a Bearer header. +- The project's build service may create and edit pull request threads once given + *Contribute to pull requests*, a thread created with `status: closed` stays closed, + and Azure DevOps renders a link reference definition in a comment as nothing. +- The checkout of a pull request build fetches with the job's credentials, and the plan + stage's `git fetch` of the target branch with the job access token in git's + environment works. +- A Branch control check on a variable group fails the stage of another branch at once, + for a run of any trigger; Check Configurations (a preview API) returns the check with + `$expand=settings` as Microsoft's Terraform provider writes it + (`evaluatebranchProtection`, `allowedBranches`, `ensureProtectionOfBranch`, + `allowUnknownStatusBranch`), and a maintainer's token with *Build (read)* reads it. +- `Environment.Name` holds the environment's name in a deployment job, and the + variables of a variable group linked by the job reach its steps when they map them. +- A secret variable the run does not define stays `$(NAME)` in the environment of a step + that maps it. +- docker on `ubuntu-24.04` runs the image as the agent's user with the sources directory + mounted, and the image's git reads the checkout made by the agent. +- The agent finds a logging command anywhere in a line of output, which is why touchmark + breaks it wherever it appears. +- Build General Settings answers `enforceSettableVar`, `enforceJobAuthScope` and + `enforceReferencedRepoScopedToken` to a maintainer's token with *Build (read)*. +- A run queued through the Runs API for `refs/heads/` with the version of + a commit that is not on that branch: whether Azure refuses it, or Branch control lets + it through on the branch's name. Until that is seen, keep who may queue runs of the + hub's pipeline to maintainers, or add an Approval check on the environment. +- The vote of the build service on a pull request counts toward a minimum number of + reviewers, which is why step 5 asks for required reviewers. + +Next: [opt a repository in](opt-in.md). diff --git a/docs/guide/ci.md b/docs/guide/ci.md index 53a44b6..bfb620f 100644 --- a/docs/guide/ci.md +++ b/docs/guide/ci.md @@ -1,8 +1,8 @@ # Run it in CI The [hub template](https://github.com/bedrock-python/engineering-assets-template) ships -the CI files for GitHub Actions, GitLab CI, Gitea and Forgejo Actions, and Bitbucket -Pipelines; each platform reads only its own. Copy them rather +the CI files for GitHub Actions, GitLab CI, Gitea and Forgejo Actions, Bitbucket +Pipelines and Azure Pipelines; each platform reads only its own. Copy them rather than writing your own: the jobs, the probe and the environment fit together. This page explains what they rely on. @@ -184,6 +184,66 @@ pipelines: - **The hub's fingerprint** is `bitbucket.org/` and `BITBUCKET_REPO_UUID` without its braces, lowercase. +## Azure Pipelines + +A hub in Azure Repos (Azure DevOps Services) runs touchmark with docker on the agent: an +Azure container job needs bash, glibc and Node.js in the image, which touchmark's Alpine +image does not have. The template's `azure-pipelines.yml` calls one step template, +`.azure-pipelines/touchmark.yml`, which runs the image pinned by digest as the agent's +user and maps the keys into the step: + +```yaml +stages: + - stage: probe # every run, without the variable group + jobs: + - job: probe + steps: + - checkout: none + - template: .azure-pipelines/touchmark.yml + parameters: { name: probe, args: probe, write: true } + - stage: check # pull request builds (Build validation) + condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest')) + jobs: + - job: check # touchmark check --hub . + - job: plan # fetch the target branch, then plan --hub . --strict --comment + - stage: distribute # pushes to the default branch, the daily schedule, Run pipeline + jobs: + - deployment: distribute + environment: touchmark-distribute + variables: + - group: touchmark-distribute + timeoutInMinutes: 60 + strategy: + runOnce: + deploy: + steps: + - checkout: self + fetchDepth: 0 + - template: .azure-pipelines/touchmark.yml + parameters: { name: distribute, args: distribute --hub . --deadline 50m, write: true, report: true } + - stage: doctor # the weekly schedule, Run pipeline: the same deployment job +``` + +- **The write key** is a secret variable of the variable group `touchmark-distribute`, + which only the deployment jobs link. Any branch's copy of the file could link it too: + the group's Branch control check, admitting the default branch only, is what keeps + the others out, and touchmark cannot read it from the job, so `platform` needs + `security.reason` there. touchmark checks `Environment.Name` (`ENVIRONMENT_NAME`) in + `distribute` and `doctor`: see + [A hub on Azure DevOps](../getting-started/azure-devops.md#where-the-write-key-can-live). +- **The hub channel** uses the job access token, `System.AccessToken`, which the step + template maps into `SYSTEM_ACCESSTOKEN`: no variable names the default branch, so + touchmark reads it, its tip and the project's visibility through the API, and keeps + `plan`'s comment with it. +- **`probe`** runs in a stage of its own, without the group, at the start of every run, + and maps the writer's variable: a write key in a pipeline variable stops the run there. +- **Pull request builds** come from the branch policy *Build validation*: Azure Repos + ignores `pr:` triggers. +- **Logging commands.** The Azure agent runs any `##vso[…]` it finds in a step's output. + Under Azure Pipelines (`TF_BUILD=True`) touchmark breaks every `##vso[` it prints, so + that nothing a platform or a target says becomes a command. +- **The hub's fingerprint** is `dev.azure.com/` and `Build.Repository.ID`, lowercase. + ## Reports, summaries and annotations In CI, `plan` and `distribute` leave their report as `touchmark-report.json` and @@ -205,6 +265,11 @@ job's artifacts. artifacts. `plan --comment` keeps the report in one comment of the hub pull request with `TOUCHMARK_PIPELINES_TOKEN`, without HTML (Bitbucket shows it as text), its marker a Markdown link reference definition. +- **Azure Pipelines:** the step template uploads the Markdown reports to the run's + summary and publishes the report files as an artifact. `plan --comment` keeps the + report in one closed comment thread of the hub pull request with the job access token, + as on Bitbucket without HTML, when the project's build service may contribute to pull + requests on the hub (see the guide for when to grant it). In a public hub, targets that are not public are only counted in all of these outputs, never named. diff --git a/docs/guide/providers.md b/docs/guide/providers.md index 155c35e..c80472f 100644 --- a/docs/guide/providers.md +++ b/docs/guide/providers.md @@ -206,9 +206,10 @@ their scopes: `doctor` reports both as unknown, so keep a reminder to rotate the A provider of type `azure-devops` is one organization of Azure DevOps Services, and its `url` names it: `https://dev.azure.com/` (one provider per organization; the old `https://.visualstudio.com` form and Azure DevOps Server are not -supported). `plan`, `distribute` and `doctor` work on it from a hub on GitHub or GitLab, -or wherever you run touchmark; a hub whose own CI is Azure Pipelines is not supported -yet, and `setup` has nothing for it. +supported). `plan`, `distribute` and `doctor` work on it from a hub on any platform, or +wherever you run touchmark, and from a hub in Azure Repos itself, whose CI is Azure +Pipelines (see [A hub on Azure DevOps](../getting-started/azure-devops.md)); `setup` has +nothing for it. ```yaml # hub.yml diff --git a/docs/index.md b/docs/index.md index b1e7f8e..bdb9164 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,7 +67,9 @@ of what each one would receive on every pull request to the hub. fails the run when a branch could see the write key. See the [security model](concepts/security.md). - **One hub, several platforms.** GitHub (github.com, GHE.com, GitHub Enterprise Server), - GitLab (gitlab.com and self-managed), Gitea and Forgejo, at once from one hub. + GitLab (gitlab.com and self-managed), Gitea, Forgejo, Bitbucket Cloud and Azure DevOps + Services, at once from one hub. Bitbucket and Azure DevOps are built from their + documentation and not yet run on a live account. ## Where to start @@ -115,7 +117,7 @@ of what each one would receive on every pull request to the hub. | | | |---|---| -| Platforms | GitHub (github.com, GHE.com, GHES 3.19+), GitLab 17.0+, Gitea 1.26+, Forgejo 15+ | +| Platforms | GitHub (github.com, GHE.com, GHES 3.19+), GitLab 17.0+, Gitea 1.26+, Forgejo 15+, Bitbucket Cloud, Azure DevOps Services | | Runs as | a GitHub Action, a container image, a single static binary | | Needs | git 2.31+ for `check`, `status`, `apply`; git 2.45+ for `plan` and `distribute` (the image brings its own) | | Configuration | `hub.yml`, `targets.yml`, `.touchmark/operations.yml` in the hub; `.engineering-assets.yml` in a target | diff --git a/docs/project/e2e.md b/docs/project/e2e.md index 5216d92..4ee708b 100644 --- a/docs/project/e2e.md +++ b/docs/project/e2e.md @@ -261,7 +261,7 @@ The tests read GitLab from the environment. Without `TOUCHMARK_E2E_GITLAB_URL`, ## The hub template -The hub template ([engineering-assets-template](https://github.com/bedrock-python/engineering-assets-template), a repository of its own) ships CI files for GitHub, GitLab, Gitea and Bitbucket that run touchmark from its release image. Its jobs are run for real, from the template's working tree, every job in the touchmark image under test, as that image's user: +The hub template ([engineering-assets-template](https://github.com/bedrock-python/engineering-assets-template), a repository of its own) ships CI files for GitHub, GitLab, Gitea, Bitbucket and Azure that run touchmark from its release image. Its jobs are run for real, from the template's working tree, every job in the touchmark image under test, as that image's user: ```sh # the image built from this working tree's Dockerfile @@ -275,9 +275,11 @@ docker run --rm --name touchmark-e2e-template-github -v "$PWD:/src:ro" -v "$PWD/ sh -c 'git config --global --add safe.directory /src && go test -count=1 -run TestTemplateWorkflow ./internal/e2e/github/' # the Bitbucket Pipelines file: its pipelines and steps, played without Bitbucket, on any OS TOUCHMARK_E2E_TEMPLATE=../engineering-assets-template go test -count=1 -run TestTemplatePipelines ./internal/e2e/bitbucket/ +# the Azure Pipelines files: their stages and steps, played without Azure DevOps, on any OS +TOUCHMARK_E2E_TEMPLATE=../engineering-assets-template go test -count=1 -run TestTemplateAzurePipelines ./internal/e2e/azure/ ``` -Without `--run`, `--template` adds `TestTemplate` to the whole suite. Without `--template`, `TestTemplate` skips, and so do `TestTemplateWorkflow` and `TestTemplatePipelines` without `TOUCHMARK_E2E_TEMPLATE` (`TestTemplatePipelines` also when the template has no `bitbucket-pipelines.yml`). `gitea.sh` runs Gitea's runner, so `--template` applies to the Gitea images; on a Forgejo image the script says so and `TestTemplate` skips. +Without `--run`, `--template` adds `TestTemplate` to the whole suite. Without `--template`, `TestTemplate` skips, and so do `TestTemplateWorkflow`, `TestTemplatePipelines` and `TestTemplateAzurePipelines` without `TOUCHMARK_E2E_TEMPLATE` (`TestTemplatePipelines` also when the template has no `bitbucket-pipelines.yml`, `TestTemplateAzurePipelines` when it has no `azure-pipelines.yml`). `gitea.sh` runs Gitea's runner, so `--template` applies to the Gitea images; on a Forgejo image the script says so and `TestTemplate` skips. ### What `--template` adds (`scripts/e2e/template-lib.sh`) @@ -303,6 +305,7 @@ The cleanup removes the runner, the registry, the forwarder and the image tags, - **`TestTemplateWorkflow`** (`internal/e2e/github/template_test.go`): a dry run of `.github/workflows/engineering-assets.yml` against `ghfake` as GHES, without GitHub and without act. The test plays the runner: each job's `if` (a small evaluator for the expressions the workflow uses: contexts, `==`, `!=`, `&&`, `||`, `!`, `always()`; strings compare without case, as GitHub's do), `needs`, the secrets and variables a job sees (the repository's, and the environment's when the job names one), the environment's deployment branch policy, each job's `GITHUB_TOKEN` (an App installed on the hub alone, minted with the job's `permissions`), `actions/checkout` (a pull request's merge commit), `run:` steps in bash with `GITHUB_OUTPUT`, and the touchmark Action: this tree's `scripts/action/run.sh` with the step's inputs (the defaults from `action.yml`), the version of a release and env, a `gh` on `PATH` that accepts the attestation of that release's digest, and a `docker` that resolves the release's tag to that digest and runs touchmark, built from this tree, by that digest, with only the environment and the user `run.sh` gives the container. The runs: the pull request that describes the hub (`check`, `plan` and its comment), its merge (`probe`, `distribute`), the doctor schedule, the daily schedule (nothing to change), Run workflow; Dependabot's pull request that bumps the Action and a pull request from a fork, which get no Actions secrets (a fork's `GITHUB_TOKEN` read-only too): `plan` runs offline, without `--strict`, and passes with its warning; then a write key leaked into a repository secret: the plan job's probe step fails, and `distribute` and `doctor` refuse (`TOUCHMARK_KEY_EXPOSED=true`). - **`TestTemplatePipelines`** (`internal/e2e/bitbucket/template_test.go`): no live Bitbucket and no runner. It reads `bitbucket-pipelines.yml` and checks what keeps the write key on the default branch: the image pinned by digest and a full clone; the pipelines (a pull request: `probe`, `check`, `plan --strict --comment`; the push of `main` and `master` and the custom `distribute`: `probe`, then `distribute`; the custom `doctor`: `probe`, then `doctor`; no default or tag pipeline); only `distribute` and `doctor` with `deployment: touchmark-distribute`; no script that names a `TOUCHMARK_` variable, and none in a custom pipeline that names any variable (Run pipeline would ask for it). Then it plays each pipeline with the variables Bitbucket gives a step (the repository's to every step, the deployment's to its steps only) through touchmark's probe and guards: everything runs on the default branch of a hub that states its Premium restriction; a custom pipeline on another branch, and `platform` without the statement, are refused; a write key leaked into a repository variable stops every pipeline at its probe. +- **`TestTemplateAzurePipelines`** (`internal/e2e/azure/template_test.go`): no live Azure DevOps and no agent. It reads `azure-pipelines.yml` and `.azure-pipelines/touchmark.yml` and checks what keeps the write key on the default branch: one step template that runs the image pinned by digest with docker, as the agent's user, mapping the writer's variable only under `write`; every checkout with the whole history; the stages (`probe` first without the variable group; `check` and `plan --strict --comment` for a pull request; `distribute` and `doctor` as deployment jobs to the environment `touchmark-distribute` that link the group `touchmark-distribute`, which nothing else links). It then plays a pull request, pushes, both schedules and both manual runs, evaluating the stages' conditions with a small evaluator (`and`, `or`, `not`, `eq`, `ne`, `in`, `succeeded()`, `variables[…]`): the expected stages run and pass touchmark's guards for a hub that states its Branch control check, `platform` without the statement is refused, and a write key leaked into a pipeline variable stops every run at its probe. ### What the runs found (2026-10-02) diff --git a/docs/project/threat-model.md b/docs/project/threat-model.md index 9bf5f1e..4527188 100644 --- a/docs/project/threat-model.md +++ b/docs/project/threat-model.md @@ -43,11 +43,12 @@ How to read the tests column: | GitHub: `TOUCHMARK_KEY_EXPOSED=false` required; `true` or unset exit 2; the environment must let the default branch alone use it, and an unreadable environment fails closed | `distribute.guardIsolation`, `distribute.CheckEnvironment` | `TestDistributeGuard`, `TestCheckEnvironment`, `TestDistributeEnvironment` *(git 2.45)*, `TestChannelEnvironment` | A private hub on GitHub Free cannot pass (no environment secrets or branch policies there): `external`, `none` or a public hub. Reading the environment of a public hub with `GITHUB_TOKEN` runs in the bedrock-python hub; a private hub's is unverified. | | `check` requires every secret a workflow hands touchmark as a write key, `with:` inputs of the Action included, to be tested by every probe expression and by the job whose output `TOUCHMARK_KEY_EXPOSED` reads (a test in another job does not count; a constant is refused), and the write key of every provider to be handed out where it can see it | `config.CheckProbe` | `TestCheckProbe`, `TestCheckProbeProviders`, `TestWriteKeySecrets` | `check` reads `.github/workflows` only, and does not follow a `TOUCHMARK_KEY_EXPOSED` that a reusable workflow takes from its inputs. | | GitLab: `touchmark probe` in merge request pipelines exits 2 when it sees a write key | `distribute.Probe`, `cli` probe command | `TestProbe`, `TestProbeCommand`, `TestCI` *(e2e GitLab)* | `distribute` cannot see a variable's flags; the probe in every merge request pipeline is what catches it. | +| Bitbucket Pipelines and Azure Pipelines: `distribute` and `doctor` need the environment `touchmark-distribute` (the step's deployment, a deployment job) and the default branch read by the hub channel; under `platform` they need `security.reason` stating what keeps other branches out (Premium deployment permissions; the variable group's Branch control check), which the job cannot read | `distribute.guardContextOf`, `distribute.guardIsolation`, `distribute.IsolationChecks`, `cli.hubRepository` | `TestDistributeGuardBitbucket`, `TestIsolationChecksBitbucket`, `TestDistributeGuardAzure`, `TestIsolationChecksAzure`, `TestHubRepositoryBitbucket`, `TestHubRepositoryAzure`, `TestTemplatePipelines`, `TestTemplateAzurePipelines` | The statement is the hub's word: `doctor --hub-token` reads the Azure check with a maintainer's token, and nothing reads Bitbucket's deployment permissions. | | Gitea and Forgejo cannot isolate the key: `platform` refused, `doctor` fails | `distribute.guardIsolation`, `distribute.IsolationChecks` | `TestDistributeGuard`, `TestIsolationChecks` | A hub on Gitea or Forgejo runs with `none` and a reason, or its CI runs on GitHub or GitLab. | | `touchmark setup` offers only an isolated write key: it refuses `security.write_isolation: none`, Gitea and Forgejo, and CI; it refuses a GitLab hub inside the group of targets and fails when the writer reaches the hub | `cli.runSetup`, `setup.GitLab`, `setup.GitHub` | `TestSetupRefusals`, `TestGitLabPreconditions`, `TestGitLabWriterReachesHub`, `TestGitHubPreconditions`, `TestGitHubFreePrivate` | — | | GitLab: setup mints the tokens straight into the hub's variables, registered with the redaction before use and never printed; the write key protected, masked, hidden and scoped to `touchmark-distribute`. A write key found unprotected, unmasked or in another scope: setup mints a new token (rotating its own revokes the old one), revokes the token each exposed variable held with that token itself (`DELETE /personal_access_tokens/self`), whoever made it, and deletes the copies; a refused revocation fails the step `writer-exposed-token` | `setup.gitlabSetup` (`mint`, `writerVariable`, `revokeExposed`) | `TestGitLabGroupTokens`, `TestGitLabServiceAccounts`, `TestGitLabRepairsWriteKey`, `TestGitLabStoreFails`, `TestSetup` *(e2e GitLab: a token made by hand in a variable of every environment is revoked)* | GitLab hides a variable only when it creates it: a protected, masked write key made by hand before setup stays readable by Maintainers in the UI (setup warns). The value of a hidden exposed copy cannot be read: setup cannot revoke its token and asks the person to (a manual step). | | GitHub: setup's page of the App manifest flow listens on a loopback address, answers GET to its own `Host` only, checks a random state per App in constant time, sends no referrer and lets its form post to GitHub alone; the code is exchanged by setup, and the Apps' private keys go to files of a private directory, never to an output; the maintainer's token goes to the hub's own API host only | `setup.manifestFlow`, `setup.githubSetup`, `cli.trustedTokenProvider` | `TestManifestFlowPage`, `TestGitHubSetup`, `TestShellQuote`, `TestSetupTokenStaysHome`, `TestSetupGitLabRefusedToken` | The key files stay on the maintainer's disk until `gh secret set` stores them and the person deletes the directory (on Windows it has the ACL of the user's temporary directory). The browser step itself is unverified: only the fake has run it. | -| `doctor --hub-token` (local only) reads where the hub keeps its secrets and variables | `hubch.ReadKeyStore`, `distribute.KeyLocationChecks` | `TestReadKeyStoreGitHub`, `TestReadKeyStoreGitLab`, `TestReadKeyStoreGitea`, `TestReadKeyStoreRefuses`, `TestKeyLocationGitHub`, `TestKeyLocationGitLab`, `TestKeyLocationGitea`, `TestKeyLocationUnread`, `TestDoctorHubTokenGitHub`, `TestDoctorHubTokenCAFile`, `TestDoctorRefusals`, `TestDoctor` *(e2e Gitea, e2e GitLab)* | The live GitHub `--hub-token` checks are unverified. | +| `doctor --hub-token` (local only) reads where the hub keeps its secrets and variables | `hubch.ReadKeyStore`, `distribute.KeyLocationChecks` | `TestReadKeyStoreGitHub`, `TestReadKeyStoreGitLab`, `TestReadKeyStoreGitea`, `TestReadKeyStoreAzure`, `TestReadKeyStoreRefuses`, `TestKeyLocationGitHub`, `TestKeyLocationGitLab`, `TestKeyLocationGitea`, `TestKeyLocationAzure`, `TestKeyLocationUnread`, `TestDoctorHubTokenGitHub`, `TestDoctorHubTokenCAFile`, `TestDoctorRefusals`, `TestDoctor` *(e2e Gitea, e2e GitLab)* | The live GitHub `--hub-token` checks are unverified. | ## Tokens and git @@ -72,6 +73,7 @@ How to read the tests column: | Opt-in file: at most 64 KiB, a bounded number of YAML anchors and aliases, a strict schema | `config` (`decodeStrict`, `ParseOptIn`), the drivers' `ReadFile` with a size limit | `TestDecodeLimits`, `TestParseHubAnchors`, `TestParseOptIn`, `TestOptInIgnoreLimit`, `FuzzParse`, `TestPlanAssumeOptInTooLarge` | — | | Marker: at most 16 KiB, at most 64 KiB inflated, strict JSON | `marker` | `TestParseGzipBomb`, `TestParseRejectsHeader`, `TestParseRejectsData`, `TestParseRejectsPayload`, `TestParseMaxChanges`, `TestFindLargeBody`, `FuzzParse`, `FuzzFind` | — | | Azure DevOps: the marker lives in a pull request property (`touchmark.marker`), apart from the description; the author, the head, the head's repository (a fork) and the closer come from the API, never from the property; a new pull request whose property cannot be stored is abandoned, or marked through an edit when the abandon fails | `azuredevops` (`markerOf`, `toPR`, `storeNewMarker`), `decide.Identity`, `distribute` (`markCreated`) | `TestPRs`, `TestCreatePRMarkerAbandons`, `TestRunAzureDevOpsMarkerLost` *(git 2.45)* | The property is not protected. Anyone who may contribute to pull requests in the project may be able to write it through the API: project Readers have "Contribute to pull requests" by default, and the REST reference names only the scope `vso.code_write` (to confirm live, [providers.md](../guide/providers.md#azure-devops)). Such a person can block a target (`blocked:marker-invalid`, by breaking the marker of touchmark's open pull request, until an `adopt` entry takes it over) or, before the team abandons a pull request, write `closed.by=touchmark` into its marker so that the decline does not count and the content is proposed again. They cannot make a pull request of their own touchmark's: its author, head and fork come from the API. | +| Azure Pipelines runs any logging command (`##vso[…]`) a step prints; under `TF_BUILD=True` touchmark breaks every `##vso[` in its output, also across writes, so no text of a platform or a target becomes a command | `cli.commandGuard` | `TestCommandGuard`, `TestMainBreaksLoggingCommands` | The template's own step prints `##vso[task.uploadsummary]` for the report files, which touchmark wrote. | | API answers: at most 32 MiB | `httpx` (`DefaultMaxBody`) | `TestMaxBody`, `TestOnlyHTTPXSpeaksHTTP` | — | | Objects of a target: a commit at most 1 MiB, a blob at most 8 MiB; more is `failed:git` | `gitx` (`MaxCommitObject`, `MaxReadBlob`), `distribute.failCommit` | `TestObjectLimits` | — | | Only the `recreate` and `repropose` checkboxes act; every other text of a target is data | `prbody.Ticked`, `prbody.Inert` | `TestTicked`, `TestUntick`, `FuzzTicked`, `TestInert`, `TestMemoryReproposeBypassesOnce`, `TestRunKeptBodyIsInert` *(git 2.45)* | — | diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index 1d1a551..84b63c3 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -41,7 +41,8 @@ changelog before you merge it. - **The command line and CI:** command and flag names, flag defaults, exit codes, environment variable names, the names of the report files in CI, the GitHub Action's inputs, and the image's tags (`X.Y.Z`, `X.Y`, `latest`) and what the hub template's jobs - use in it (`sh`, `touchmark` on the `PATH`, user 65532). + use in it (`sh`, `touchmark` on the `PATH`, user 65532, and `touchmark` as the entrypoint, + which the Azure Pipelines step runs as the agent's user). ## Not stable diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 254c028..672199a 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -32,6 +32,7 @@ done. A Gitea or Forgejo admin token is refused. | `GITHUB_TOKEN` | the hub channel on GitHub Actions (and on Gitea and Forgejo when `GITEA_TOKEN` is unset): the tip of the default branch, the hub's visibility, the environment's branch policy, `plan --comment` | | `CI_JOB_TOKEN` | the hub channel on GitLab CI | | `GITEA_TOKEN` | the hub channel on Gitea and Forgejo Actions | +| `SYSTEM_ACCESSTOKEN` | the hub channel on Azure Pipelines: the job access token, `System.AccessToken`, which a step gets only when it maps it (`env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)`): the default branch, its tip, the project's visibility, `plan --comment` | | `TOUCHMARK_PIPELINES_TOKEN` | the hub channel on Bitbucket Pipelines, which gives a step no API token: an access token of the hub repository with *Repositories: Read* (the default branch, its tip, the visibility) and *Pull requests: Write* (`plan --comment`) | | `CI` | any value but `""`, `false` and `0` makes a run a CI run: operation flags exit 2, and an unknown hub visibility counts as public | @@ -42,7 +43,11 @@ and its base (the scope of `plan`), the project's visibility, on GitLab `CI_COMMIT_REF_PROTECTED`, `CI_ENVIRONMENT_NAME` and `CI_SERVER_TLS_CA_FILE`, and on Bitbucket Pipelines `BITBUCKET_REPO_UUID`, `BITBUCKET_REPO_FULL_NAME`, `BITBUCKET_BRANCH`, `BITBUCKET_TAG`, `BITBUCKET_PR_ID`, `BITBUCKET_DEPLOYMENT_ENVIRONMENT` and -`BITBUCKET_REPO_IS_PRIVATE`. +`BITBUCKET_REPO_IS_PRIVATE`, and on Azure Pipelines `TF_BUILD`, `SYSTEM_COLLECTIONURI`, +`SYSTEM_TEAMPROJECT`, `BUILD_REPOSITORY_PROVIDER`, `BUILD_REPOSITORY_ID`, +`BUILD_REPOSITORY_NAME`, `BUILD_REPOSITORY_URI`, `BUILD_SOURCEBRANCH`, `BUILD_REASON`, +`SYSTEM_PULLREQUEST_SOURCEBRANCH`, `SYSTEM_PULLREQUEST_PULLREQUESTID` and +`ENVIRONMENT_NAME`. ## In the GitHub Action diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 8e6661a..453322b 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -19,7 +19,8 @@ with the writer, from the hub's default branch. **fingerprint** — the hub's identity: the host and immutable repository id of the hub, like `github.com/712345678`; on Bitbucket the repository's UUID, lowercase and without -braces, like `bitbucket.org/3f2a8d4e-1b6c-4f0a-9e7d-5c2b1a0f9e8d`. In the commit +braces, like `bitbucket.org/3f2a8d4e-1b6c-4f0a-9e7d-5c2b1a0f9e8d`; on Azure DevOps the +repository's id, lowercase, like `dev.azure.com/0b7e5a2c-9d4f-4e1b-8a3c-6f5d2e1c0b9a`. In the commit trailers and the marker. CI provides it; `--hub-fp` outside CI. **hub** — the repository, created from the template, that holds the packs, `hub.yml`, diff --git a/docs/reference/hub.md b/docs/reference/hub.md index 0e1846c..cff2f2f 100644 --- a/docs/reference/hub.md +++ b/docs/reference/hub.md @@ -25,7 +25,7 @@ memory: auto_close_cooldown: 30d # a bot's close: proposed again after 30d, then 60d; the third counts security: write_isolation: platform # platform | external | none - # reason: "..." # required with none, and with platform on Bitbucket Pipelines + # reason: "..." # required with none, and with platform on Bitbucket and Azure Pipelines private_targets_in_public_hub: skip # skip | deliver providers: - id: gh # TOUCHMARK_GH_* variables; gh:owner/name in targets.yml @@ -64,8 +64,9 @@ The schema cannot say everything. `touchmark check` also requires: - `commit.message` and `pr.title` are not blank and do not start with `Draft:` or `WIP:` (set `pr.draft` instead); `commit.message` holds no git scissors line; - `security.reason` is set when `write_isolation` is `none` (and `distribute` and - `doctor` on Bitbucket Pipelines need it under `platform` too: see - [A hub on Bitbucket Cloud](../getting-started/bitbucket.md)); + `doctor` on Bitbucket Pipelines and Azure Pipelines need it under `platform` too: see + [A hub on Bitbucket Cloud](../getting-started/bitbucket.md) and + [A hub on Azure DevOps](../getting-started/azure-devops.md)); - no line of the file looks like a token (`ghp_`, `github_pat_`, `ghs_`, `glpat-`, `-----BEGIN`). @@ -82,7 +83,7 @@ In a hub pull request, `providers` and `ca_file` are read from the default branc | `id` | string, 3 to 40 characters, required | Short slug naming this hub, used in the sync branch name and in pull request markers. The template's placeholder "change-me" fails `touchmark check`. | | `branch` | string | Sync branch in every target. Default: touchmark/<id>. | | `branch_aliases` | list of strings | Former sync branch names. Open pull requests on them are still recognised as this hub's own. | -| `previous_fingerprints` | list of strings | Former fingerprints of this hub (host/repository-id: a numeric id, or a Bitbucket repository UUID with or without braces), after the hub moved. | +| `previous_fingerprints` | list of strings | Former fingerprints of this hub (host/repository-id: a numeric id, a Bitbucket repository UUID with or without braces, or an Azure Repos repository id), after the hub moved. | | `opt_in_file` | string | Path of the opt-in file in targets. Default: .engineering-assets.yml. | | `commit` | object | Commit settings. The commit author is always the provider's writer account. | | `commit.message` | string | Commit message. Default: "chore: sync engineering assets". Must not start with Draft: or WIP:, which turns a GitLab merge request into a draft. | @@ -101,8 +102,8 @@ In a hub pull request, `providers` and `ca_file` are read from the default branc | `memory` | object | Memory of pull requests closed without merging. | | `memory.auto_close_cooldown` | string | How long content closed by a bot waits before it is proposed again, in days or hours. Default: 30d. | | `security` | object | — | -| `security.write_isolation` | one of `platform`, `external`, `none` | Where the write key lives. platform: in the hub's CI platform, checked by a probe (on Bitbucket Pipelines only with a reason); external: issued by an external vault over OIDC; none: risk accepted, needs a reason. Default: platform. | -| `security.reason` | string | Why the write key cannot be isolated. Required with write_isolation: none, and on Bitbucket Pipelines with platform, where it states the deployment permissions (Premium) that keep the write key on the default branch, which touchmark cannot read. | +| `security.write_isolation` | one of `platform`, `external`, `none` | Where the write key lives. platform: in the hub's CI platform, checked by a probe (on Bitbucket Pipelines and Azure Pipelines only with a reason); external: issued by an external vault over OIDC; none: risk accepted, needs a reason. Default: platform. | +| `security.reason` | string | Why the write key cannot be isolated. Required with write_isolation: none, and with platform on Bitbucket Pipelines, where it states the deployment permissions (Premium) that keep the write key on the default branch, and on Azure Pipelines, where it states the Branch control check of the variable group that holds it: touchmark cannot read either. | | `security.private_targets_in_public_hub` | one of `skip`, `deliver` | What a public hub does with non-public targets: skip them, or deliver and let their names reach public CI logs. Default: skip. | | `providers` | list of objects | Platforms this hub delivers to. Targets refer to them by id. | | `providers[].id` | string, required | Name targets.yml uses for this provider, and the <ID> of its TOUCHMARK_<ID>_\* variables. | diff --git a/docs/reference/output.md b/docs/reference/output.md index 36d3b38..1ec5c5e 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -72,7 +72,7 @@ and the branch head before and after. | `engine` | string, required | touchmark's version. | | `hub` | object, required | The hub the run read. | | `hub.id` | string, required | hub.yml's id. | -| `hub.fingerprint` | string, required | The hub's host and immutable repository id, e.g. github.com/712345678; on Bitbucket the repository UUID, lowercase without braces. | +| `hub.fingerprint` | string, required | The hub's host and immutable repository id, e.g. github.com/712345678; on Bitbucket the repository UUID, lowercase without braces; on Azure DevOps the repository id, lowercase. | | `hub.commit` | string, required | Full commit id: 40 or 64 lowercase hex digits. | | `hub.pr` | integer ≥ 1 | The hub pull request plan ran on. | | `outcome` | one of `completed`, `superseded`, required | completed, or superseded when the hub's default branch had moved on and nothing was inspected or written. | @@ -189,7 +189,7 @@ hub, for each provider's write identity, and for each target. See | `engine` | string, required | touchmark's version. | | `hub` | object, required | The hub the run read. | | `hub.id` | string, required | hub.yml's id. | -| `hub.fingerprint` | string, required | The hub's host and immutable repository id, e.g. github.com/712345678; on Bitbucket the repository UUID, lowercase without braces. | +| `hub.fingerprint` | string, required | The hub's host and immutable repository id, e.g. github.com/712345678; on Bitbucket the repository UUID, lowercase without braces; on Azure DevOps the repository id, lowercase. | | `hub.commit` | string, required | Full commit id: 40 or 64 lowercase hex digits. | | `strict` | boolean, required | The run had --strict: a check that warns or is unknown, or an incomplete resolve, exits with code 3. | | `hub_token` | boolean, required | doctor --hub-token read, with a maintainer's token, where the hub keeps its secrets. | diff --git a/internal/cli/azure_hub_test.go b/internal/cli/azure_hub_test.go new file mode 100644 index 0000000..f0682b5 --- /dev/null +++ b/internal/cli/azure_hub_test.go @@ -0,0 +1,216 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/bedrock-python/touchmark/internal/config" + "github.com/bedrock-python/touchmark/internal/httpx" + "github.com/bedrock-python/touchmark/internal/hubch" + "github.com/bedrock-python/touchmark/internal/redact" + "github.com/bedrock-python/touchmark/internal/report" +) + +// The hub on Azure DevOps: its repository and project, and the job access +// token of its pipeline, which acts as azBuildService. +const ( + azHubRepo = "0b7e5a2c-9d4f-4e1b-8a3c-6f5d2e1c0b9a" + azHubProject = "7d1e3c5a-2b4f-4a6e-9c8d-1f0e2d3c4b5a" + azBuildService = "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9" + azJobToken = "az-job-access-token-0123456789" +) + +// azPipelines is a step of the hub's pipeline on Azure Pipelines, with the +// job access token mapped into it. +func azPipelines(extra map[string]string) map[string]string { + vars := map[string]string{ + "TF_BUILD": "True", + "SYSTEM_COLLECTIONURI": "https://dev.azure.com/acme/", + "SYSTEM_TEAMPROJECT": "Platform", + "BUILD_REPOSITORY_PROVIDER": "TfsGit", + "BUILD_REPOSITORY_ID": azHubRepo, + "BUILD_REPOSITORY_NAME": "engineering-assets", + hubch.AzureTokenVar: azJobToken, + } + for k, v := range extra { + vars[k] = v + } + return vars +} + +// azHubAPI serves the hub repository's answers to the hub channel and +// records the comment threads created on pull request 41. +type azHubAPI struct { + *httptest.Server + mu sync.Mutex + writes []string // "METHOD path: content" + auth map[string]bool +} + +func newAzHubAPI(t *testing.T) *azHubAPI { + t.Helper() + s := &azHubAPI{auth: map[string]bool{}} + const repo = "/acme/_apis/git/repositories/" + azHubRepo + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.auth[r.Header.Get("Authorization")] = true + w.Header().Set("Content-Type", "application/json") + if r.Header.Get("Authorization") != "Bearer "+azJobToken { + http.Error(w, `{"message": "TF400813"}`, http.StatusUnauthorized) + return + } + switch { + case r.Method == http.MethodGet && r.URL.Path == repo: + fmt.Fprint(w, `{"id": "`+azHubRepo+`", "name": "engineering-assets", "defaultBranch": "refs/heads/master", + "project": {"id": "`+azHubProject+`", "name": "Platform", "visibility": "private"}}`) + case r.Method == http.MethodGet && r.URL.Path == "/acme/_apis/connectionData": + fmt.Fprint(w, `{"authenticatedUser": {"id": "`+azBuildService+`"}}`) + case r.Method == http.MethodGet && r.URL.Path == repo+"/pullRequests/41/threads": + fmt.Fprint(w, `{"count": 0, "value": []}`) + case r.Method == http.MethodPost && r.URL.Path == repo+"/pullRequests/41/threads": + var in struct { + Comments []struct { + Content string `json:"content"` + } `json:"comments"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + for _, c := range in.Comments { + s.writes = append(s.writes, r.Method+" "+r.URL.Path+": "+c.Content) + } + fmt.Fprint(w, `{"id": 9}`) + default: + http.Error(w, `{"message": "not found"}`, http.StatusNotFound) + } + })) + t.Cleanup(s.Close) + return s +} + +// azDetectAt is hubch.Detect of vars with the API of srv. +func azDetectAt(vars map[string]string, srv *httptest.Server) hubch.Context { + c := hubch.Detect(func(k string) string { return vars[k] }, nil) + c.APIURL = srv.URL + "/acme" + return c +} + +// On Azure Pipelines no variable names the default branch: the hub channel +// reads it with the job access token before any guard. +func TestHubRepositoryAzure(t *testing.T) { + srv := newAzHubAPI(t) + vars := azPipelines(map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/master", "BUILD_REASON": "IndividualCI"}) + hctx, warnings := hubRepository(t.Context(), azDetectAt(vars, srv.Server), func(k string) string { return vars[k] }) + if len(warnings) != 0 || hctx.DefaultBranch != "master" || hctx.Visibility != "private" { + t.Errorf("context %+v, warnings %q", hctx, warnings) + } + // A step that does not map the token: a warning that says how, no + // default branch, and so distribute's guard refuses. + delete(vars, hubch.AzureTokenVar) + hctx, warnings = hubRepository(t.Context(), azDetectAt(vars, srv.Server), func(k string) string { return vars[k] }) + if hctx.DefaultBranch != "" || len(warnings) != 1 || !strings.Contains(warnings[0], "env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)") { + t.Errorf("without a token: %+v, %q", hctx, warnings) + } +} + +// plan --comment on an Azure DevOps hub pull request: one closed thread +// through the hub channel with the job access token, plain Markdown with a +// reference definition as its marker. +func TestPlanCommentAzure(t *testing.T) { + srv := newAzHubAPI(t) + vars := azPipelines(map[string]string{"BUILD_SOURCEBRANCH": "refs/pull/41/merge", "BUILD_REASON": "PullRequest", + "SYSTEM_PULLREQUEST_SOURCEBRANCH": "refs/heads/feature", "SYSTEM_PULLREQUEST_PULLREQUESTID": "41"}) + getenv := func(k string) string { return vars[k] } + hctx := azDetectAt(vars, srv.Server) + if pr := hubPR(hctx, getenv); pr != 41 { + t.Fatalf("hubPR = %d", pr) + } + reg := redact.New() + reg.Add(azJobToken) + ch, err := hubch.New(hctx, httpx.New(httpx.Options{Redact: reg}), hubch.Token(hctx, getenv)) + if err != nil { + t.Fatal(err) + } + rep := report.NewDelivery("plan", "dev") + rep.Hub.ID = "acme-eng" + rep.Paths = []report.PathChange{{Action: "add", Path: "AGENTS.md", Targets: 2}} + if w := postPlanComment(t.Context(), hctx, ch, 41, rep, reg); w != "" { + t.Fatalf("warning %q", w) + } + srv.mu.Lock() + defer srv.mu.Unlock() + if len(srv.writes) != 1 { + t.Fatalf("writes %q", srv.writes) + } + body := srv.writes[0] + if !strings.HasSuffix(body, "\n\n[touchmark-plan]: # \"touchmark plan: acme-eng\"\n") || strings.Contains(body, "<") { + t.Errorf("comment:\n%s", body) + } + if !srv.auth["Bearer "+azJobToken] || len(srv.auth) != 1 { + t.Errorf("Authorization headers %v", srv.auth) + } +} + +// --hub-fp takes an Azure Repos repository id in any case, and keeps it in +// the form the CI reports. +func TestHubFPAzure(t *testing.T) { + var f fingerprintFlag + if err := f.Set("dev.azure.com/" + strings.ToUpper(azHubRepo)); err != nil || string(f) != "dev.azure.com/"+azHubRepo { + t.Errorf("--hub-fp: %q, %v", f, err) + } + vars := azPipelines(map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/master"}) + hctx := hubch.Detect(func(k string) string { return vars[k] }, nil) + fp, warnings, err := planFingerprint(hctx, "dev.azure.com/"+azHubRepo) + if err != nil || len(warnings) != 0 || fp != "dev.azure.com/"+azHubRepo { + t.Errorf("planFingerprint: %q, %q, %v", fp, warnings, err) + } + if got := config.CanonicalFingerprint("Dev.Azure.com:443/" + strings.ToUpper(azHubRepo)); got != "dev.azure.com/"+azHubRepo { + t.Errorf("CanonicalFingerprint: %q", got) + } +} + +// commandGuard breaks every Azure Pipelines logging command, also one split +// across writes, and keeps everything else byte for byte. +func TestCommandGuard(t *testing.T) { + var out bytes.Buffer + g := newCommandGuard(&out) + for _, chunk := range []string{"ok ##vso[task.setvariable variable=X]1\n", "path: ##", "vso[task.prependpath]/tmp", " #", "# end\n", "##v"} { + if n, err := g.Write([]byte(chunk)); err != nil || n != len(chunk) { + t.Fatalf("Write(%q) = %d, %v", chunk, n, err) + } + } + if err := g.Flush(); err != nil { + t.Fatal(err) + } + want := "ok ##vso [task.setvariable variable=X]1\npath: ##vso [task.prependpath]/tmp ## end\n##v" + if out.String() != want { + t.Errorf("guarded %q\nwant %q", out.String(), want) + } +} + +// Under Azure Pipelines everything touchmark prints goes through the guard; +// elsewhere it is left as it is. +func TestMainBreaksLoggingCommands(t *testing.T) { + for _, tc := range []struct { + tfBuild, want string + }{{"True", "##vso [task.setvariable"}, {"", "##vso[task.setvariable"}} { + var stdout, stderr bytes.Buffer + e := &env{stdout: &stdout, stderr: &stderr, getenv: func(k string) string { + if k == "TF_BUILD" { + return tc.tfBuild + } + return "" + }} + if code := e.main(t.Context(), []string{"##vso[task.setvariable variable=X]1"}); code != exitUsage { + t.Errorf("TF_BUILD=%q: exit %d", tc.tfBuild, code) + } + if !strings.Contains(stderr.String(), tc.want) { + t.Errorf("TF_BUILD=%q: stderr %q lacks %q", tc.tfBuild, stderr.String(), tc.want) + } + } +} diff --git a/internal/cli/azure_out.go b/internal/cli/azure_out.go new file mode 100644 index 0000000..04262fe --- /dev/null +++ b/internal/cli/azure_out.go @@ -0,0 +1,73 @@ +package cli + +import ( + "bytes" + "io" + "strings" + "sync" +) + +// The Azure Pipelines agent reads a logging command, ##vso[area.action +// …]…, anywhere in a line a step prints, and runs it: it sets variables for +// the later steps, uploads files, prepends to PATH +// (https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands). +// touchmark prints what the platforms and the targets say (error messages, +// paths of files in the targets), so on Azure Pipelines (TF_BUILD=True) +// everything it prints goes through commandGuard, which breaks every +// "##vso[" so that the agent sees no command in it. + +// vsoPrefix starts an Azure Pipelines logging command, and vsoBroken is +// what commandGuard prints instead. +const ( + vsoPrefix = "##vso[" + vsoBroken = "##vso [" +) + +// commandGuard is a writer that breaks every vsoPrefix written through it, +// also one split across writes: it holds back a tail that may begin one +// until the next write, or Flush. It is safe for concurrent use. +type commandGuard struct { + mu sync.Mutex + w io.Writer + tail []byte +} + +func newCommandGuard(w io.Writer) *commandGuard { return &commandGuard{w: w} } + +// Write writes p, with every vsoPrefix broken; it reports len(p) written +// when the underlying writer took all it was given. +func (g *commandGuard) Write(p []byte) (int, error) { + g.mu.Lock() + defer g.mu.Unlock() + buf := append(g.tail, p...) + buf = bytes.ReplaceAll(buf, []byte(vsoPrefix), []byte(vsoBroken)) + keep := heldBack(buf) + g.tail = append([]byte(nil), buf[len(buf)-keep:]...) + if _, err := g.w.Write(buf[:len(buf)-keep]); err != nil { + return 0, err + } + return len(p), nil +} + +// Flush writes what Write held back. +func (g *commandGuard) Flush() error { + g.mu.Lock() + defer g.mu.Unlock() + if len(g.tail) == 0 { + return nil + } + _, err := g.w.Write(g.tail) + g.tail = nil + return err +} + +// heldBack returns the length of the longest suffix of buf that is a proper +// prefix of vsoPrefix: the next write could complete it. +func heldBack(buf []byte) int { + for n := min(len(vsoPrefix)-1, len(buf)); n > 0; n-- { + if strings.HasPrefix(vsoPrefix, string(buf[len(buf)-n:])) { + return n + } + } + return 0 +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2f41c39..fa25b34 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -39,6 +39,7 @@ import ( "fmt" "io" "os" + "strings" ) // Version, Commit and Date describe the build. A release sets them with @@ -118,6 +119,15 @@ func Main(ctx context.Context, args []string, stdout, stderr io.Writer) int { } func (e *env) main(ctx context.Context, args []string) int { + if strings.EqualFold(strings.TrimSpace(e.getenv("TF_BUILD")), "true") { + // Azure Pipelines: no logging command in anything touchmark prints. + out, errOut := newCommandGuard(e.stdout), newCommandGuard(e.stderr) + defer func() { _ = errOut.Flush() }() + defer func() { _ = out.Flush() }() + guarded := *e + guarded.stdout, guarded.stderr = out, errOut + e = &guarded + } if len(args) == 0 { e.usage(e.stderr) return exitUsage diff --git a/internal/cli/delivery.go b/internal/cli/delivery.go index e5aa22a..3aa4f73 100644 --- a/internal/cli/delivery.go +++ b/internal/cli/delivery.go @@ -232,17 +232,18 @@ type failedChannel struct{ err error } func (c failedChannel) Head(context.Context) (string, error) { return "", c.err } // hubRepository fills in what the CI does not say about the hub: Bitbucket -// Pipelines names no default branch, so on Bitbucket the hub channel reads -// it, with the hub's access token (TOUCHMARK_PIPELINES_TOKEN; a public hub -// answers without one), and the visibility when BITBUCKET_REPO_IS_PRIVATE -// was missing. It reads the hub repository only, before any guard: the -// guards then compare the run's branch with it. When the channel cannot -// read it the context stays without a default branch, with a warning: -// distribute and doctor refuse to run then (distribute.DistributeGuard), -// and plan reads its providers without credentials. Every other CI is -// returned as it is. +// Pipelines and Azure Pipelines name no default branch, so there the hub +// channel reads it, and the visibility when the CI names none: on Bitbucket +// with the hub's access token (TOUCHMARK_PIPELINES_TOKEN; a public hub +// answers without one), on Azure with the job access token +// (SYSTEM_ACCESSTOKEN, mapped into the step). It reads the hub repository +// only, before any guard: the guards then compare the run's branch with it. +// When the channel cannot read it the context stays without a default +// branch, with a warning: distribute and doctor refuse to run then +// (distribute.DistributeGuard), and plan reads its providers without +// credentials. Every other CI is returned as it is. func hubRepository(ctx context.Context, hctx hubch.Context, getenv func(string) string) (hubch.Context, []string) { - if hctx.CI != hubch.BitbucketPipelines || hctx.DefaultBranch != "" { + if (hctx.CI != hubch.BitbucketPipelines && hctx.CI != hubch.AzurePipelines) || hctx.DefaultBranch != "" { return hctx, nil } reg := redact.New() @@ -260,11 +261,17 @@ func hubRepository(ctx context.Context, hctx hubch.Context, getenv func(string) return r.Repository(ctx) }() if err != nil { - hint := "" - if token == "" { + ci, hint := "Bitbucket Pipelines", "" + switch { + case hctx.CI == hubch.AzurePipelines: + ci = "Azure Pipelines" + if token == "" { + hint = "; map the job access token into the step: env: " + hubch.AzureTokenVar + ": $(System.AccessToken)" + } + case token == "": hint = "; a private hub needs " + hubch.BitbucketTokenVar + ", an access token of the hub repository with Repositories: Read" } - return hctx, []string{reg.Replace(fmt.Sprintf("Bitbucket Pipelines names no default branch of the hub, and the hub channel could not read it: %v%s", err, hint))} + return hctx, []string{reg.Replace(fmt.Sprintf("%s names no default branch of the hub, and the hub channel could not read it: %v%s", ci, err, hint))} } hctx.DefaultBranch = info.DefaultBranch if hctx.Visibility == "" { diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 530d647..89989a3 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -300,12 +300,9 @@ func (h *hub) keyLocation(ctx context.Context, e *env, rps []config.ResolvedProv i := strings.LastIndexByte(fp, '/') host, id := strings.ToLower(fp[:i]), fp[i+1:] in := hubch.KeyStoreInput{RepoID: id, Token: token} - var pp *planProvider - for _, rp := range rps { - if strings.EqualFold(rp.Host, host) { - pp = &planProvider{ResolvedProvider: rp} - break - } + pp, err := h.hubProvider(ctx, rps, host) + if err != nil { + return nil, err } switch { case pp != nil: @@ -354,6 +351,35 @@ func (h *hub) keyLocation(ctx context.Context, e *env, rps []config.ResolvedProv return distribute.KeyLocationChecks(ks, config.WriteKeySecrets(workflows), h.cfg), nil } +// hubProvider returns the provider of hub.yml on the hub's host, nil when +// none is there. Azure DevOps puts every organization on dev.azure.com, and +// the fingerprint names the repository alone: with providers of several +// organizations there, the one of the hub's origin remote +// (https://dev.azure.com//…) is the hub's. +func (h *hub) hubProvider(ctx context.Context, rps []config.ResolvedProvider, host string) (*planProvider, error) { + var on []config.ResolvedProvider + for _, rp := range rps { + if strings.EqualFold(rp.Host, host) { + on = append(on, rp) + } + } + switch { + case len(on) == 0: + return nil, nil + case len(on) == 1 || on[0].Type != "azure-devops": + return &planProvider{ResolvedProvider: on[0]}, nil + } + org, _ := strings.CutPrefix(h.originHost(ctx), host+"/") + for _, rp := range on { + if o, ok := config.AzureDevOpsOrg(rp.URL); ok && org != "" && strings.EqualFold(o, org) { + return &planProvider{ResolvedProvider: rp}, nil + } + } + return nil, configErrorf("--hub-token: %s has several Azure DevOps organizations on %s, and the hub's origin remote names none of them: "+ + "run doctor --hub-token from a clone of the hub, whose origin is https://%s///_git/ "+ + "or its SSH form", config.HubFile, host, host) +} + // hubAPIHost is the host name of the API of a hub on host (a fingerprint's // host, perhaps with a port) on platform typ: api.github.com for // github.com, api. for GHE.com, api.bitbucket.org for bitbucket.org, diff --git a/internal/cli/plan.go b/internal/cli/plan.go index acb1b3e..340df6a 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -305,9 +305,10 @@ func planFingerprint(hctx hubch.Context, flag string) (string, []string, error) warnings = append(warnings, fmt.Sprintf("--hub-fp %s differs from %s, the fingerprint CI reports", flag, ci)) } if flag == "" { - return "", nil, configErrorf("the hub's fingerprint is unknown: CI provides it (GITHUB_REPOSITORY_ID, CI_PROJECT_ID, BITBUCKET_REPO_UUID); " + - "outside CI pass --hub-fp HOST/ID, the hub's host and repository id, e.g. github.com/712345678 " + - "(on Bitbucket the repository's UUID, BITBUCKET_REPO_UUID: bitbucket.org/{…})") + return "", nil, configErrorf("the hub's fingerprint is unknown: CI provides it (GITHUB_REPOSITORY_ID, CI_PROJECT_ID, BITBUCKET_REPO_UUID, " + + "BUILD_REPOSITORY_ID); outside CI pass --hub-fp HOST/ID, the hub's host and repository id, e.g. github.com/712345678 " + + "(on Bitbucket the repository's UUID, BITBUCKET_REPO_UUID: bitbucket.org/{…}; on Azure DevOps the repository's id, " + + "Build.Repository.ID: dev.azure.com/…)") } return flag, warnings, nil } @@ -387,17 +388,22 @@ func (h *hub) resolveProviders(ctx context.Context, hctx hubch.Context, getenv f } // originHost returns the host of the hub's origin remote, "" when there is -// none or it is not a URL touchmark understands. +// none or it is not a URL touchmark understands. For a hub in Azure Repos it +// is "dev.azure.com/" (config.AzureOrigin): the host alone +// does not name the organization. func (h *hub) originHost(ctx context.Context) string { out, err := h.git.Run(ctx, nil, "remote", "get-url", "origin") if err != nil { return "" } - // The URL may hold credentials: only its host is kept. - host, _, ok := parseRemote(strings.TrimSpace(string(out))) + // The URL may hold credentials: only its host and path are kept. + host, path, ok := parseRemote(strings.TrimSpace(string(out))) if !ok { return "" } + if o := config.AzureOrigin(host, path); o != "" { + return o + } return host } @@ -683,8 +689,9 @@ func (h *hub) caPool(ctx context.Context, name, rev string) (*x509.CertPool, err // hubPR returns the number of the hub pull request a CI run builds: from // GITHUB_REF (refs/pull//merge) on Actions, CI_MERGE_REQUEST_IID on -// GitLab, BITBUCKET_PR_ID on Bitbucket Pipelines; 0 when the run builds -// none. +// GitLab, BITBUCKET_PR_ID on Bitbucket Pipelines, +// SYSTEM_PULLREQUEST_PULLREQUESTID on Azure Pipelines; 0 when the run +// builds none. func hubPR(hctx hubch.Context, getenv func(string) string) int64 { var n string switch hctx.CI { @@ -698,6 +705,8 @@ func hubPR(hctx hubch.Context, getenv func(string) string) int64 { n = strings.TrimSpace(getenv("CI_MERGE_REQUEST_IID")) case hubch.BitbucketPipelines: n = strings.TrimSpace(getenv("BITBUCKET_PR_ID")) + case hubch.AzurePipelines: + n = strings.TrimSpace(getenv("SYSTEM_PULLREQUEST_PULLREQUESTID")) } v, err := strconv.ParseInt(n, 10, 64) if err != nil || v <= 0 { diff --git a/internal/cli/plan_comment.go b/internal/cli/plan_comment.go index a9d1c35..cc8f443 100644 --- a/internal/cli/plan_comment.go +++ b/internal/cli/plan_comment.go @@ -15,8 +15,9 @@ import ( // the hub channel with the CI's own token (GITHUB_TOKEN with // pull-requests: write in the plan job; the Actions token of Gitea and // Forgejo; on Bitbucket the hub's access token in TOUCHMARK_PIPELINES_TOKEN -// with Pull requests: Write). GitLab has no comment: CI_JOB_TOKEN cannot -// write merge request notes. +// with Pull requests: Write; on Azure Pipelines the job access token in +// SYSTEM_ACCESSTOKEN). GitLab has no comment: CI_JOB_TOKEN cannot write +// merge request notes. // planCommentMarker is the hidden line that tells plan's comment from the // others: one per hub, so that two hubs planning in one repository (a @@ -28,14 +29,15 @@ func planCommentMarker(hubID string) string { // planCommentMarkerOf is the marker of plan's comment in CI ci. Bitbucket // shows an HTML comment as text, so there the marker is a Markdown link // reference definition, which Markdown does not show either (as the marker -// of a sync pull request there, marker.FrameRefDef): +// of a sync pull request there, marker.FrameRefDef); Azure DevOps, whose +// rendering of HTML in comments touchmark has not seen, gets the same: // // [touchmark-plan]: # "touchmark plan: " // // It needs a blank line before it, which the report's last line gives. // Every other CI keeps the HTML comment, byte for byte. func planCommentMarkerOf(ci hubch.CI, hubID string) string { - if ci == hubch.BitbucketPipelines { + if plainComment(ci) { return `[touchmark-plan]: # "touchmark plan: ` + hubID + `"` } return planCommentMarker(hubID) @@ -64,8 +66,8 @@ func postPlanComment(ctx context.Context, hctx hubch.Context, ch hubch.Channel, } marker := planCommentMarkerOf(hctx.CI, rep.Hub.ID) write := rep.WriteComment - if hctx.CI == hubch.BitbucketPipelines { - // Bitbucket shows HTML as text: no
sections. + if plainComment(hctx.CI) { + // No
sections: Bitbucket shows HTML as text. write = rep.WritePlainComment } var b strings.Builder @@ -74,10 +76,21 @@ func postPlanComment(ctx context.Context, hctx hubch.Context, ch hubch.Channel, } body := reg.Replace(b.String()) if _, err := c.UpsertComment(ctx, pr, marker, body); err != nil { - if hctx.CI == hubch.BitbucketPipelines { + switch hctx.CI { + case hubch.BitbucketPipelines: return what + fmt.Sprintf("not posted: %v (the hub's access token in %s needs Pull requests: Write)", err, hubch.BitbucketTokenVar) + case hubch.AzurePipelines: + return what + fmt.Sprintf("not posted: %v (the step maps the job access token into %s, and the project's build service "+ + "needs Contribute to pull requests on the hub)", err, hubch.AzureTokenVar) } return what + fmt.Sprintf("not posted: %v", err) } return "" } + +// plainComment reports whether plan's comment in CI ci is plain Markdown, +// without HTML: Bitbucket shows HTML as text, and Azure DevOps' rendering +// of it in comments is not known. +func plainComment(ci hubch.CI) bool { + return ci == hubch.BitbucketPipelines || ci == hubch.AzurePipelines +} diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 3f6c444..36e6dc4 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -102,8 +102,9 @@ func runSetup(ctx context.Context, e *env, o *options, s *setupOptions) (err err "tokens, the hub's access token, the repository and deployment variables), as %s shows; a hub on GitHub or GitLab "+ "delivers to Bitbucket Cloud as one of its providers", docsurl.GettingStarted("bitbucket")) case "azure-devops": - return configErrorf("setup does not set up azure-devops yet: a hub on Azure Pipelines comes in a later release; " + - "a hub on GitHub or GitLab delivers to Azure DevOps as one of its providers") + return configErrorf("setup does not set up azure-devops yet: set up a hub on Azure Pipelines by hand (the bot users and their "+ + "personal access tokens, the pipeline and its build validation policy, the variable group and the environment), as %s shows; "+ + "a hub on GitHub or GitLab delivers to Azure DevOps as one of its providers", docsurl.GettingStarted("azure-devops")) case "github", "gitlab": default: return usageErrorf("unknown platform %q: setup sets up github or gitlab", platform) diff --git a/internal/config/providers.go b/internal/config/providers.go index 9da404e..04e869a 100644 --- a/internal/config/providers.go +++ b/internal/config/providers.go @@ -69,6 +69,34 @@ func AzureDevOpsOrg(raw string) (org string, ok bool) { return org, true } +// AzureCollectionURL returns the organization URL, +// https://dev.azure.com/, of an Azure DevOps Services +// collection URL as Azure Pipelines gives it (System.CollectionUri): +// https://dev.azure.com//, or the older +// https://.visualstudio.com/ form of the same organization; +// "" for anything else (Azure DevOps Server). +func AzureCollectionURL(raw string) string { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || !strings.EqualFold(u.Scheme, "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "" + } + if p := u.Port(); p != "" && p != "443" { + return "" + } + host, path := strings.ToLower(u.Hostname()), strings.Trim(u.Path, "/") + var org string + switch { + case host == azureDevOpsHost && !strings.Contains(path, "/"): + org = path + case strings.HasSuffix(host, ".visualstudio.com") && path == "": + org = strings.TrimSuffix(host, ".visualstudio.com") + } + if !azureOrgRe.MatchString(org) { + return "" + } + return "https://" + azureDevOpsHost + "/" + org +} + // azureDevOpsURLOK reports whether raw, a checked URL, can be an // azure-devops provider's url: one organization segment, and, without an // api_url, on https://dev.azure.com. @@ -204,7 +232,7 @@ func (h *Hub) ResolveProvidersWithOrigin(getenv func(string) string, originHost type ciEnv struct { // kind is the provider type of the CI: "github", "gitlab", "gitea", // "forgejo", "bitbucket" (Bitbucket Pipelines, always on Bitbucket - // Cloud), or "" outside CI. + // Cloud), "azure-devops" (Azure Pipelines), or "" outside CI. kind string // serverVar names the variable with the platform's URL, and serverURL // is its value, trimmed. @@ -232,6 +260,20 @@ func readCIEnv(getenv func(string) string) ciEnv { // server URL: the hub is on https://bitbucket.org. env.kind, env.serverURL = "bitbucket", bitbucketURL return env + case isTrue("TF_BUILD"): + // Azure Pipelines: the hub's organization is System.CollectionUri, + // on dev.azure.com or in the older .visualstudio.com form, both + // https://dev.azure.com/ as a provider. A build of a repository + // outside Azure Repos, or on Azure DevOps Server, names none. + env.kind, env.serverVar = "azure-devops", "SYSTEM_COLLECTIONURI" + if strings.EqualFold(strings.TrimSpace(getenv("BUILD_REPOSITORY_PROVIDER")), "TfsGit") { + collection := getenv("SYSTEM_COLLECTIONURI") + if strings.TrimSpace(collection) == "" { + collection = getenv("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI") + } + env.serverURL = AzureCollectionURL(collection) + } + return env default: return env } @@ -251,6 +293,9 @@ func readCIEnv(getenv func(string) string) ciEnv { func implicitProvider(h *Hub, env ciEnv, originHost string) (Provider, error) { var p Provider switch { + case env.kind == "azure-devops" && env.serverURL == "": + return Provider{}, fmt.Errorf("%s declares no providers, and this Azure Pipelines build is of no Azure Repos repository of Azure DevOps "+ + "Services (Build.Repository.Provider TfsGit, System.CollectionUri on dev.azure.com): add providers to %s", HubFile, HubFile) case env.kind != "": if env.serverURL == "" { return Provider{}, fmt.Errorf("%s declares no providers and %s is not set; add providers to %s", HubFile, env.serverVar, HubFile) @@ -262,8 +307,9 @@ func implicitProvider(h *Hub, env ciEnv, originHost string) (Provider, error) { default: var ok bool if p, ok = originProvider(originHost); !ok { - return Provider{}, fmt.Errorf("%s declares no providers, no CI environment names one (GitHub Actions, GitLab CI, Gitea or Forgejo Actions, Bitbucket Pipelines), "+ - "and the hub's origin remote is not on github.com, a *.ghe.com host, gitlab.com or bitbucket.org; add providers to %s, or set platform and base_url there", HubFile, HubFile) + return Provider{}, fmt.Errorf("%s declares no providers, no CI environment names one (GitHub Actions, GitLab CI, Gitea or Forgejo Actions, Bitbucket Pipelines, "+ + "Azure Pipelines), "+ + "and the hub's origin remote is not on github.com, a *.ghe.com host, gitlab.com, bitbucket.org or dev.azure.com; add providers to %s, or set platform and base_url there", HubFile, HubFile) } } if h != nil { @@ -274,18 +320,48 @@ func implicitProvider(h *Hub, env ciEnv, originHost string) (Provider, error) { // OriginTellsProvider reports whether a hub without providers can take its // implicit provider outside CI from an origin remote on host: only a public -// instance (github.com, a *.ghe.com host, gitlab.com, bitbucket.org) tells -// its type. +// instance (github.com, a *.ghe.com host, gitlab.com, bitbucket.org, +// dev.azure.com, whose origin also names the organization) tells its type. func OriginTellsProvider(host string) bool { + if strings.EqualFold(strings.TrimSpace(host), azureDevOpsHost) { + return true + } _, ok := originProvider(host) return ok } +// AzureOrigin returns the origin of a hub in Azure Repos as +// ResolveProvidersWithOrigin takes it, "dev.azure.com/", from +// the host and path of its origin remote: https://dev.azure.com//…, +// git@ssh.dev.azure.com:v3//…, or https://.visualstudio.com/…; +// "" for any other remote. +func AzureOrigin(host, path string) string { + host = strings.ToLower(strings.TrimSpace(host)) + parts := strings.Split(strings.Trim(path, "/"), "/") + var org string + switch { + case host == azureDevOpsHost && len(parts) >= 3: + org = parts[0] + case host == "ssh."+azureDevOpsHost && len(parts) >= 3 && parts[0] == "v3": + org = parts[1] + case strings.HasSuffix(host, ".visualstudio.com") && host != "ssh.visualstudio.com" && host != "vs-ssh.visualstudio.com": + org = strings.TrimSuffix(host, ".visualstudio.com") + } + if !azureOrgRe.MatchString(org) { + return "" + } + return azureDevOpsHost + "/" + strings.ToLower(org) +} + // originProvider returns the provider of a public instance at host, the // host of a hub's origin remote: only there does the host tell the type -// (Bitbucket Cloud's bitbucket.org included). +// (Bitbucket Cloud's bitbucket.org included). For Azure DevOps the origin +// is "dev.azure.com/" (AzureOrigin). func originProvider(host string) (Provider, bool) { host = strings.ToLower(strings.TrimSpace(host)) + if org, ok := strings.CutPrefix(host, azureDevOpsHost+"/"); ok && azureOrgRe.MatchString(org) { + return Provider{ID: "azure-devops", Type: "azure-devops", URL: "https://" + azureDevOpsHost + "/" + org}, true + } switch { case host == "github.com": return Provider{ID: "github", Type: "github", URL: "https://github.com"}, true diff --git a/internal/config/providers_test.go b/internal/config/providers_test.go index e0f5568..09b3483 100644 --- a/internal/config/providers_test.go +++ b/internal/config/providers_test.go @@ -226,6 +226,17 @@ func TestResolveProvidersImplicit(t *testing.T) { env: envOf("BITBUCKET_BUILD_NUMBER", "17", "CI", "true", "BITBUCKET_REPO_UUID", "{3f2a8d4e-1b6c-4f0a-9e7d-5c2b1a0f9e8d}"), want: implicit("bitbucket", "https://bitbucket.org", "bitbucket.org", "https://api.bitbucket.org/2.0", ""), }, + { + // The organization of Azure DevOps Services the build runs in. + name: "Azure Pipelines", + env: envOf("TF_BUILD", "True", "BUILD_REPOSITORY_PROVIDER", "TfsGit", "SYSTEM_COLLECTIONURI", "https://dev.azure.com/acme/"), + want: implicit("azure-devops", "https://dev.azure.com/acme", "dev.azure.com", "https://dev.azure.com/acme", ""), + }, + { + name: "Azure Pipelines, the older organization URL", + env: envOf("TF_BUILD", "True", "BUILD_REPOSITORY_PROVIDER", "TfsGit", "SYSTEM_COLLECTIONURI", "https://Acme.visualstudio.com/"), + want: implicit("azure-devops", "https://dev.azure.com/acme", "dev.azure.com", "https://dev.azure.com/acme", ""), + }, } legacy, _, err := ParseHub(nil) if err != nil { @@ -244,6 +255,17 @@ func TestResolveProvidersImplicit(t *testing.T) { } } + // Azure Pipelines building a repository elsewhere, or on Azure DevOps + // Server, names no provider. + for _, env := range []func(string) string{ + envOf("TF_BUILD", "True", "BUILD_REPOSITORY_PROVIDER", "GitHub", "SYSTEM_COLLECTIONURI", "https://dev.azure.com/acme/"), + envOf("TF_BUILD", "True", "BUILD_REPOSITORY_PROVIDER", "TfsGit", "SYSTEM_COLLECTIONURI", "https://tfs.example.com/DefaultCollection/"), + } { + if _, err := (&Hub{ID: "acme-eng"}).ResolveProviders(env); err == nil || !strings.Contains(err.Error(), "no Azure Repos repository") { + t.Errorf("Azure Pipelines without a hub in Azure Repos: %v", err) + } + } + // A hub built in code with the top-level writer and sign. hub := &Hub{ID: "acme-eng", Writer: "acme-bot", Sign: "always"} got, err := hub.ResolveProviders(envOf("GITLAB_CI", "true", "CI_SERVER_URL", "https://gitlab.com")) @@ -280,7 +302,7 @@ func TestResolveProvidersErrors(t *testing.T) { name: "no providers outside CI", hub: &Hub{ID: "acme-eng"}, env: envOf("CI", "true", "GITHUB_ACTIONS", "1"), - want: "hub.yml declares no providers, no CI environment names one (GitHub Actions, GitLab CI, Gitea or Forgejo Actions, Bitbucket Pipelines), and the hub's origin remote is not on github.com", + want: "hub.yml declares no providers, no CI environment names one (GitHub Actions, GitLab CI, Gitea or Forgejo Actions, Bitbucket Pipelines, Azure Pipelines), and the hub's origin remote is not on github.com", }, { name: "no server URL", @@ -725,3 +747,28 @@ func TestOptInHash(t *testing.T) { t.Errorf("Hash changed its receiver: %+v", in) } } + +// A hub in Azure Repos takes its implicit provider outside CI from its +// origin remote, whose path names the organization. +func TestAzureOrigin(t *testing.T) { + for _, tc := range []struct{ host, path, want string }{ + {"dev.azure.com", "Acme/Platform/_git/engineering-assets", "dev.azure.com/acme"}, + {"ssh.dev.azure.com", "v3/acme/Platform/engineering-assets", "dev.azure.com/acme"}, + {"acme.visualstudio.com", "Platform/_git/engineering-assets", "dev.azure.com/acme"}, + {"dev.azure.com", "acme", ""}, + {"ssh.dev.azure.com", "v2/acme/Platform/x", ""}, + {"vs-ssh.visualstudio.com", "v3/acme/Platform/x", ""}, + {"github.com", "acme/engineering-assets", ""}, + } { + if got := AzureOrigin(tc.host, tc.path); got != tc.want { + t.Errorf("AzureOrigin(%q, %q) = %q, want %q", tc.host, tc.path, got, tc.want) + } + } + got, err := (&Hub{ID: "acme-eng"}).ResolveProvidersWithOrigin(envOf(), "dev.azure.com/acme") + if err != nil || len(got) != 1 || got[0].Type != "azure-devops" || got[0].URL != "https://dev.azure.com/acme" || got[0].APIURL != "https://dev.azure.com/acme" { + t.Errorf("from the origin: %+v, %v", got, err) + } + if !OriginTellsProvider("dev.azure.com") { + t.Error("dev.azure.com: an origin there names its organization") + } +} diff --git a/internal/distribute/azure_hub_test.go b/internal/distribute/azure_hub_test.go new file mode 100644 index 0000000..1d16e50 --- /dev/null +++ b/internal/distribute/azure_hub_test.go @@ -0,0 +1,199 @@ +package distribute + +import ( + "strings" + "testing" + + "github.com/bedrock-python/touchmark/internal/config" + "github.com/bedrock-python/touchmark/internal/gitx" + "github.com/bedrock-python/touchmark/internal/hubch" + "github.com/bedrock-python/touchmark/internal/report" +) + +// azDeploy is the deployment job of a hub on Azure Pipelines: its default +// branch (read by the hub channel), in the environment touchmark-distribute. +var azDeploy = hubch.Context{CI: hubch.AzurePipelines, Host: "dev.azure.com", RepoID: "0b7e5a2c-9d4f-4e1b-8a3c-6f5d2e1c0b9a", + DefaultBranch: "main", RefName: "main", RefIsBranch: true, Event: "push", Environment: "touchmark-distribute"} + +// azChecked is a hub that states the Branch control check of its variable +// group. +func azChecked() *config.Hub { + return hubWithIsolation("platform", "Azure: a Branch control check admits refs/heads/main only to touchmark-distribute") +} + +// The guards of distribute and doctor on Azure Pipelines: a hub in Azure +// Repos, the default branch, no pull request, the environment +// touchmark-distribute, and under platform the hub's statement of the +// check. +func TestDistributeGuardAzure(t *testing.T) { + with := func(edit func(*hubch.Context)) hubch.Context { + c := azDeploy + edit(&c) + return c + } + for _, tc := range []struct { + name string + c hubch.Context + hub *config.Hub + want []string + }{ + {"checked", azDeploy, azChecked(), nil}, + {"none", azDeploy, hubWithIsolation("none", "no checks yet"), nil}, + {"external", azDeploy, hubWithIsolation("external", ""), nil}, + {"platform without a statement", azDeploy, hubWithIsolation("platform", ""), []string{"Branch control check", "security.reason"}}, + {"the default isolation", azDeploy, &config.Hub{ID: "acme-eng"}, []string{"Branch control check"}}, + {"no environment", with(func(c *hubch.Context) { c.Environment = "" }), azChecked(), []string{"deployment job to the environment touchmark-distribute"}}, + {"another environment", with(func(c *hubch.Context) { c.Environment = "production" }), azChecked(), []string{"ENVIRONMENT_NAME"}}, + {"pull request", with(func(c *hubch.Context) { c.Event, c.RefName, c.RefIsBranch = "pull_request", "feature", false }), azChecked(), + []string{"does not run for the pull_request event", "feature, which is not a branch"}}, + {"other branch", with(func(c *hubch.Context) { c.RefName = "feature" }), azChecked(), []string{"default branch main, not on feature"}}, + {"tag", with(func(c *hubch.Context) { c.RefName, c.RefIsBranch = "v1", false }), azChecked(), []string{"v1, which is not a branch"}}, + {"default branch unread", with(func(c *hubch.Context) { c.DefaultBranch = "" }), azChecked(), + []string{"Azure Pipelines names none", "SYSTEM_ACCESSTOKEN"}}, + // A build of a repository outside Azure Repos has no hub. + {"no hub in Azure Repos", hubch.Context{CI: hubch.AzurePipelines}, azChecked(), []string{"only for a hub in Azure Repos"}}, + } { + t.Run(tc.name, func(t *testing.T) { + err := DistributeGuard(t.Context(), GuardInput{Context: tc.c, Hub: tc.hub, Getenv: guardEnv{}.get, GitVersion: gitx.DeliveryMinVersion}) + if len(tc.want) == 0 { + if err != nil { + t.Errorf("error %v", err) + } + return + } + if err == nil { + t.Fatalf("no error, want %q", tc.want) + } + for _, w := range tc.want { + if !strings.Contains(err.Error(), w) { + t.Errorf("error %q lacks %q", err, w) + } + } + }) + } + if err := DoctorGuard(with(func(c *hubch.Context) { c.Environment = "" }), guardEnv{}.get); err == nil || !strings.Contains(err.Error(), "doctor needs a deployment job") { + t.Errorf("doctor without the environment: %v", err) + } + if err := DoctorGuard(azDeploy, guardEnv{}.get); err != nil { + t.Errorf("doctor in the environment: %v", err) + } +} + +func TestIsolationChecksAzure(t *testing.T) { + status := func(h *config.Hub) (report.CheckStatus, string) { + cs := IsolationChecks(IsolationInput{Context: azDeploy, Hub: h}) + if len(cs) != 1 { + t.Fatalf("checks %+v", cs) + } + return cs[0].Status, cs[0].Detail + } + if s, d := status(hubWithIsolation("platform", "")); s != report.StatusFail || !strings.Contains(d, "Branch control check") { + t.Errorf("platform without a statement: %s %s", s, d) + } + if s, d := status(azChecked()); s != report.StatusUnknown || !strings.Contains(d, "the hub states: Azure: a Branch control") { + t.Errorf("checked: %s %s", s, d) + } + if s, _ := status(hubWithIsolation("none", "no checks yet")); s != report.StatusWarn { + t.Errorf("none: %s", s) + } +} + +func TestKeyLocationAzure(t *testing.T) { + mainOnly := hubch.BranchCheck{Allowed: []string{"refs/heads/main"}, Protection: true} + store := func(groups ...hubch.VariableGroup) hubch.KeyStore { + return hubch.KeyStore{Platform: "azure-devops", DefaultBranch: "main", VariableGroups: groups, Secrets: []hubch.Secret{ + {Name: "TOUCHMARK_READ_TOKEN", Where: "pipeline", Group: "hub", Masked: true}, + {Name: "TOUCHMARK_WRITE_TOKEN", Where: "variable group", Group: "touchmark-distribute", Masked: true}, + }} + } + check := func(ks hubch.KeyStore, hub *config.Hub, status report.CheckStatus, sub string) { + t.Helper() + lines := keyChecks(KeyLocationChecks(ks, nil, hub), "key-location") + if !hasCheck(lines, status, sub) { + t.Errorf("no %s check with %q in:\n%s", status, sub, strings.Join(lines, "\n")) + } + } + group := func(checks ...hubch.BranchCheck) hubch.VariableGroup { + return hubch.VariableGroup{ID: 5, Name: "touchmark-distribute", BranchChecks: checks} + } + + ok := store(group(mainOnly)) + check(ok, azChecked(), report.StatusOK, "admits the default branch main only, and verifies its protection") + if lines := keyChecks(KeyLocationChecks(ok, nil, azChecked()), "key-location"); len(lines) != 1 { + t.Errorf("a well kept key: %d checks:\n%s", len(lines), strings.Join(lines, "\n")) + } + // One check that admits main alone is enough: a stage passes only when + // every check does. + check(store(group(hubch.BranchCheck{Allowed: []string{"refs/heads/*"}}, mainOnly)), azChecked(), report.StatusOK, "admits the default branch main only") + check(store(group()), azChecked(), report.StatusFail, "has no Branch control check") + check(store(group(hubch.BranchCheck{Allowed: []string{"refs/heads/main", "refs/heads/release/*"}, Protection: true})), azChecked(), + report.StatusFail, "admit refs/heads/release/* besides the default branch main") + check(store(group(hubch.BranchCheck{Allowed: []string{"refs/heads/main"}})), azChecked(), report.StatusWarn, "none verifies branch protection") + // A bare branch name never matches the run's full ref: no run passes. + check(store(group(hubch.BranchCheck{Allowed: []string{"main"}, Protection: true})), azChecked(), report.StatusWarn, "without refs/heads/") + // A check whose branches touchmark could not read is not taken for one + // that admits the default branch alone. + check(store(group(hubch.BranchCheck{Protection: true})), azChecked(), report.StatusUnknown, "lists no branch that touchmark can read") + check(store(group(hubch.BranchCheck{Allowed: []string{"refs/heads/main"}, Protection: true, AllowUnknown: true})), azChecked(), + report.StatusWarn, "lets an unknown status pass") + check(store(hubch.VariableGroup{Name: "touchmark-distribute", ChecksUnread: "the checks of variable group touchmark-distribute: HTTP 403"}), azChecked(), + report.StatusUnknown, "its checks could not be read") + open := group(mainOnly) + open.AllPipelines = true + check(store(open), azChecked(), report.StatusWarn, "open to every pipeline of the project") + // Without Verify branch protection, another repository's pipeline on its + // own main passes the check. + openUnverified := group(hubch.BranchCheck{Allowed: []string{"refs/heads/main"}}) + openUnverified.AllPipelines = true + check(store(openUnverified), azChecked(), report.StatusFail, "open to every pipeline of the project") + unreadPerms := group(mainOnly) + unreadPerms.PermissionsUnread = "the pipeline permissions of variable group touchmark-distribute: HTTP 403" + check(store(unreadPerms), azChecked(), report.StatusUnknown, "which pipelines may use variable group touchmark-distribute could not be read") + check(store(group()), hubWithIsolation("none", "no checks yet"), report.StatusWarn, "accepted the risk") + + // A write key among the pipeline's variables fails; one not secret + // warns; one in another group warns. + leaked := store(group(mainOnly)) + leaked.Secrets = append(leaked.Secrets, + hubch.Secret{Name: "TOUCHMARK_GH_WRITE_TOKEN", Where: "pipeline", Group: "hub", Masked: true}, + hubch.Secret{Name: "TOUCHMARK_SIGNING_KEY", Where: "variable group", Group: "shared"}) + leaked.VariableGroups = append(leaked.VariableGroups, hubch.VariableGroup{ID: 6, Name: "shared", BranchChecks: []hubch.BranchCheck{mainOnly}}) + check(leaked, azChecked(), report.StatusFail, "variable TOUCHMARK_GH_WRITE_TOKEN of pipeline hub: every run of the pipeline") + check(leaked, azChecked(), report.StatusWarn, "variable TOUCHMARK_SIGNING_KEY of variable group shared: distribute's stage links the variable group touchmark-distribute") + check(leaked, azChecked(), report.StatusWarn, "TOUCHMARK_SIGNING_KEY of variable group shared is not secret") + + // No group touchmark-distribute fails; groups that could not be listed + // are unknown. + bare := hubch.KeyStore{Platform: "azure-devops", DefaultBranch: "main"} + check(bare, azChecked(), report.StatusFail, "has no variable group touchmark-distribute") + bare.VariableGroupsUnread = "the project's variable groups: HTTP 403" + check(bare, azChecked(), report.StatusUnknown, "is not known") +} + +func TestPipelineSettingsAzure(t *testing.T) { + grade := func(st *hubch.PipelineSettings, unread string) report.DoctorCheck { + t.Helper() + ks := hubch.KeyStore{Platform: "azure-devops", DefaultBranch: "main", PipelineSettings: st, PipelineSettingsUnread: unread} + for _, c := range KeyLocationChecks(ks, nil, azChecked()) { + if c.Name == "pipeline-settings" { + return c + } + } + t.Fatal("no pipeline-settings check") + return report.DoctorCheck{} + } + if c := grade(&hubch.PipelineSettings{SettableVarsLimited: true, JobScopeLimited: true, ReposProtected: true}, ""); c.Status != report.StatusOK { + t.Errorf("all on: %+v", c) + } + if c := grade(&hubch.PipelineSettings{JobScopeLimited: true, ReposProtected: true}, ""); c.Status != report.StatusFail || + !strings.Contains(c.Detail, "BASH_ENV") { + t.Errorf("queue-time variables: %+v", c) + } + if c := grade(&hubch.PipelineSettings{SettableVarsLimited: true}, ""); c.Status != report.StatusWarn || + !strings.Contains(c.Detail, "job authorization scope") || !strings.Contains(c.Detail, "Protect access to repositories") { + t.Errorf("token scope: %+v", c) + } + if c := grade(nil, "the project's pipeline settings: HTTP 403"); c.Status != report.StatusUnknown { + t.Errorf("unread: %+v", c) + } +} diff --git a/internal/distribute/doctor_hub.go b/internal/distribute/doctor_hub.go index 280b317..4cfea7d 100644 --- a/internal/distribute/doctor_hub.go +++ b/internal/distribute/doctor_hub.go @@ -20,9 +20,9 @@ import ( // DoctorGuard runs the guards of doctor before any platform call: in CI // (a CI hubch recognizes, or CI set), the job builds the hub's default -// branch for no pull request, and on GitLab and Bitbucket in the -// environment touchmark-distribute, as distribute's (the writer is used on -// the default branch only). A local run passes. +// branch for no pull request, and on GitLab, Bitbucket and Azure +// Pipelines in the environment touchmark-distribute, as distribute's (the +// writer is used on the default branch only). A local run passes. func DoctorGuard(c hubch.Context, getenv func(string) string) error { if !guardInCI(c, getenv) { return nil @@ -60,6 +60,10 @@ type IsolationInput struct { // - platform, on Bitbucket Pipelines: fail without security.reason // (BitbucketPlatformRefusal); with it unknown: the API does not show // who may deploy, and the reason states it; +// - platform, on Azure Pipelines: fail without security.reason +// (AzurePlatformRefusal); with it unknown: the job's token is not +// known to read the variable group's checks, and the reason states +// them; // - platform, locally: unknown, doctor --hub-token tells. func IsolationChecks(in IsolationInput) []report.DoctorCheck { mode := "platform" @@ -129,6 +133,18 @@ func IsolationChecks(in IsolationInput) []report.DoctorCheck { c.Status, c.Detail = report.StatusUnknown, "Bitbucket's API does not show which branches may deploy to "+distributeEnvironment+ " (Premium deployment permissions); the hub states: "+reason+". `touchmark probe` in the hub's pipelines fails when a repository "+ "or workspace variable holds a write key, and doctor --hub-token reads the variables" + case hubch.AzurePipelines: + reason := "" + if in.Hub != nil { + reason = strings.TrimSpace(in.Hub.Security.Reason) + } + if reason == "" { + c.Status, c.Detail = report.StatusFail, AzurePlatformRefusal + break + } + c.Status, c.Detail = report.StatusUnknown, "a job cannot read the checks of the variable group "+distributeEnvironment+ + "; the hub states: "+reason+". `touchmark probe` in the hub's pull request builds fails when a pipeline variable holds a "+ + "write key, and doctor --hub-token reads the variable groups and their checks" default: c.Status, c.Detail = report.StatusUnknown, "a local run cannot see where the hub's CI keeps the write key: doctor --hub-token reads it with a maintainer's token" } @@ -177,6 +193,16 @@ func IsWriteKeyName(name string, known []string) bool { // does not show which branches may deploy), ok when only admins may // deploy there, a warning under none, and a warning in another // environment; one not secured warns. +// - Azure DevOps: a write key in a variable of a pipeline that builds +// the hub fails (every run reads it, of any branch, pull request +// builds included); one in a variable group is ok when the group has a +// Branch control check that admits the default branch alone, fails +// without one or with another branch, is unknown when its checks could +// not be read, and warns in another group than touchmark-distribute or +// under none; a check without Verify branch protection, and a key that +// is not secret, warn; a group every pipeline may use warns, and fails +// without Verify branch protection. The project's pipeline settings +// are graded too (azureSettingsChecks). // // Under security.write_isolation external no write key is expected in the // hub, and one found there is graded the same. A key found nowhere is @@ -207,6 +233,8 @@ func KeyLocationChecks(ks hubch.KeyStore, known []string, cfg *config.Hub) []rep out = append(out, gitlabKeyChecks(s)...) case "bitbucket": out = append(out, bitbucketKeyChecks(s, envs, mode)...) + case "azure-devops": + out = append(out, azureKeyChecks(s, ks, mode)...) default: if mode == "none" { add(report.StatusWarn, "Actions secret %s (%s): every branch's jobs read it; the hub accepted the risk (security.write_isolation: none)", s.Name, s.Where) @@ -227,6 +255,18 @@ func KeyLocationChecks(ks hubch.KeyStore, known []string, cfg *config.Hub) []rep add(report.StatusFail, "the hub has no environment %s: keep the write key in its secrets (its deployment variables on Bitbucket), with deployments limited to the default branch", distributeEnvironment) } } + if ks.Platform == "azure-devops" && mode == "platform" && !slices.ContainsFunc(ks.VariableGroups, func(g hubch.VariableGroup) bool { + return strings.EqualFold(g.Name, distributeEnvironment) + }) { + if ks.VariableGroupsUnread != "" { + add(report.StatusUnknown, "whether the hub's project has the variable group %s is not known: %s", distributeEnvironment, ks.VariableGroupsUnread) + } else { + add(report.StatusFail, "the hub's project has no variable group %s: keep the write key in it, as a secret, with a Branch control check that admits the default branch only", distributeEnvironment) + } + } + if ks.Platform == "azure-devops" { + out = append(out, azureSettingsChecks(ks)...) + } if ks.Platform == "gitlab" { out = append(out, gitlabRefChecks(ks)...) } @@ -300,6 +340,156 @@ func bitbucketKeyChecks(s hubch.Secret, envs map[string]hubch.NamedEnvironment, return out } +// azureKeyChecks grade one write-key variable on Azure DevOps. +func azureKeyChecks(s hubch.Secret, ks hubch.KeyStore, mode string) []report.DoctorCheck { + c := report.DoctorCheck{Name: "key-location"} + where := fmt.Sprintf("variable %s of %s %s", s.Name, s.Where, s.Group) + var extra []report.DoctorCheck + switch s.Where { + case "pipeline": + c.Status, c.Detail = report.StatusFail, fmt.Sprintf("%s: every run of the pipeline reads it, of any branch, pull request builds included "+ + "(any branch's YAML may map a secret variable); move it into the variable group %s", where, distributeEnvironment) + case "variable group": + var g hubch.VariableGroup + for _, vg := range ks.VariableGroups { + if vg.Name == s.Group { + g = vg + break + } + } + c.Status, c.Detail = azureGroupCheck(where, g, ks.DefaultBranch) + switch { + case mode == "none": + c.Status, c.Detail = report.StatusWarn, where+": the hub accepted the risk that other branches read it (security.write_isolation: none)" + case !strings.EqualFold(s.Group, distributeEnvironment) && c.Status == report.StatusOK: + c.Status, c.Detail = report.StatusWarn, fmt.Sprintf("%s: distribute's stage links the variable group %s", where, distributeEnvironment) + } + switch { + case g.PermissionsUnread != "": + extra = append(extra, report.DoctorCheck{Name: "key-location", Status: report.StatusUnknown, + Detail: fmt.Sprintf("which pipelines may use variable group %s could not be read: %s", s.Group, g.PermissionsUnread)}) + case g.AllPipelines: + // Branch control compares the name of the run's branch: the + // pipeline of another repository of the project, run on its own + // default branch, passes it unless the check also verifies the + // branch's protection, and even then a protected default branch + // of another repository does. + status := report.StatusWarn + if !azureVerified(g) { + status = report.StatusFail + } + extra = append(extra, report.DoctorCheck{Name: "key-location", Status: status, + Detail: fmt.Sprintf("variable group %s is open to every pipeline of the project: Branch control compares only the run's branch name, "+ + "so a pipeline of another repository, run on its own default branch, may link the group and read the key; under Pipeline "+ + "permissions give the hub's pipeline alone the use of it", s.Group)}) + } + default: + c.Status, c.Detail = report.StatusUnknown, fmt.Sprintf("variable %s (%s)", s.Name, s.Where) + } + out := append([]report.DoctorCheck{c}, extra...) + if !s.Masked { + out = append(out, report.DoctorCheck{Name: "key-location", Status: report.StatusWarn, + Detail: where + " is not secret: its value shows in the Library and the API, every step gets it in its environment, and the logs do not mask it; make it secret"}) + } + return out +} + +// azureVerified reports whether a Branch control check of g verifies the +// branch's protection without letting an unknown status pass. +func azureVerified(g hubch.VariableGroup) bool { + return slices.ContainsFunc(g.BranchChecks, func(bc hubch.BranchCheck) bool { return bc.Protection && !bc.AllowUnknown }) +} + +// azureGroupCheck grades the Branch control checks of a variable group that +// holds a write key. A stage passes only when every check passes, so one +// check that admits the default branch alone (refs/heads/) keeps +// every other branch out: ok when one does and one verifies branch +// protection without letting an unknown status pass; warn when none +// verifies it, or when a check names the branch without refs/heads/ (it +// then admits no run at all); fail when no check admits the default branch +// alone; unknown when the checks could not be read, or a check lists no +// branch (its settings are not in the shape touchmark reads). +func azureGroupCheck(where string, g hubch.VariableGroup, defaultBranch string) (report.CheckStatus, string) { + if g.ChecksUnread != "" { + return report.StatusUnknown, where + ": its checks could not be read (" + g.ChecksUnread + "); a maintainer's token with Build: Read reads them" + } + if len(g.BranchChecks) == 0 { + return report.StatusFail, where + ": the group has no Branch control check, so a stage of any branch that links it reads the key: " + + "add one that admits refs/heads/" + guardRef(defaultBranch) + " only (Pipelines > Library > the group > Approvals and checks)" + } + want := "refs/heads/" + defaultBranch + restricted, bare := false, false + var others []string + for _, bc := range g.BranchChecks { + if len(bc.Allowed) == 0 { + return report.StatusUnknown, where + ": a Branch control check of the group lists no branch that touchmark can read; check its " + + "Allowed branches by hand" + } + var extra []string + for _, b := range bc.Allowed { + switch { + case defaultBranch != "" && b == want: + case defaultBranch != "" && b == defaultBranch: + bare = true + default: + extra = append(extra, b) + } + } + if len(extra) == 0 { + restricted = true + } + others = append(others, extra...) + } + switch { + case !restricted: + slices.Sort(others) + return report.StatusFail, where + ": its Branch control checks admit " + strings.Join(slices.Compact(others), ", ") + + " besides the default branch " + guardRef(defaultBranch) + ", whose stages then read the key: admit refs/heads/" + guardRef(defaultBranch) + " only" + case bare: + return report.StatusWarn, where + ": a Branch control check names the branch " + defaultBranch + " without refs/heads/, while the check " + + "compares the run's full ref: write refs/heads/" + defaultBranch + ", or distribute's stage never starts" + case !azureVerified(g): + return report.StatusWarn, where + ": a Branch control check admits the default branch " + defaultBranch + " only, but none verifies " + + "branch protection (or it lets an unknown status pass): turn Verify branch protection on, so the key reaches only a default branch that takes no direct push" + } + return report.StatusOK, where + ": a Branch control check admits the default branch " + defaultBranch + " only, and verifies its protection" +} + +// azureSettingsChecks grade the Azure DevOps project's pipeline settings +// that bear on the write key (check pipeline-settings): variables settable +// at queue time fail (whoever may queue a run of the default branch sets +// BASH_ENV or DOCKER_HOST in the steps that hold the key); a job scope wider +// than the project, and repositories not limited to those a pipeline +// checks out, warn. +func azureSettingsChecks(ks hubch.KeyStore) []report.DoctorCheck { + c := report.DoctorCheck{Name: "pipeline-settings"} + st := ks.PipelineSettings + if st == nil { + c.Status, c.Detail = report.StatusUnknown, "the project's pipeline settings could not be read: "+ks.PipelineSettingsUnread + return []report.DoctorCheck{c} + } + var fails, warns []string + if !st.SettableVarsLimited { + fails = append(fails, "Limit variables that can be set at queue time is off: whoever may queue a run of the default branch sets any "+ + "variable in the steps that hold the write key (BASH_ENV runs a command, DOCKER_HOST sends the container elsewhere)") + } + if !st.JobScopeLimited { + warns = append(warns, "Limit job authorization scope to current project is off: the job access token reaches every project of the organization") + } + if !st.ReposProtected { + warns = append(warns, "Protect access to repositories in YAML pipelines is off: the job access token reaches every repository of the project") + } + switch { + case len(fails) > 0: + c.Status, c.Detail = report.StatusFail, strings.Join(append(fails, warns...), "; ") + case len(warns) > 0: + c.Status, c.Detail = report.StatusWarn, strings.Join(warns, "; ") + default: + c.Status, c.Detail = report.StatusOK, "queue-time variables are limited, and the job access token is limited to the project and the repositories a pipeline checks out" + } + return []report.DoctorCheck{c} +} + // gitlabKeyChecks grade one write-key variable on GitLab. func gitlabKeyChecks(s hubch.Secret) []report.DoctorCheck { where := "project variable " + s.Name diff --git a/internal/distribute/guards.go b/internal/distribute/guards.go index 4c92000..68af01b 100644 --- a/internal/distribute/guards.go +++ b/internal/distribute/guards.go @@ -50,12 +50,15 @@ const ( // merge_request_event; on GitLab also CI_COMMIT_REF_PROTECTED=true and // CI_ENVIRONMENT_NAME=touchmark-distribute; on Bitbucket Pipelines the // step's deployment touchmark-distribute (BITBUCKET_DEPLOYMENT_ENVIRONMENT); +// on Azure Pipelines a deployment job to the environment +// touchmark-distribute (ENVIRONMENT_NAME); // - write isolation (security.write_isolation): "platform" on GitHub // Actions needs TOUCHMARK_KEY_EXPOSED=false (true: the key is visible // outside the environment; unset: the probe job is missing); "none" // needs security.reason (config enforces it) and adds a red report // warning; "external" skips the probe. "platform" on Bitbucket -// Pipelines needs security.reason too (guardIsolation). +// Pipelines and Azure Pipelines needs security.reason too +// (guardIsolation). // // A dry run gets every check: it holds the write credential as distribute // does, and builds its drivers from the hub.yml of the ref it runs on (no @@ -77,8 +80,12 @@ const ( // probe. On Bitbucket Pipelines it holds only on Premium, whose // deployment permissions keep other branches (or people) out of the // environment; the API does not show them, so the hub states them in -// security.reason, and without that statement "platform" is refused. The red warning for "none" is the caller's to add to the -// report: the guards return errors only. +// security.reason, and without that statement "platform" is refused. On +// Azure Pipelines the variable group that holds the key keeps other +// branches out only with a Branch control check, which a job's token +// is not known to read: the hub states it in security.reason too. The +// red warning for "none" is the caller's to add to the report: the +// guards return errors only. // // Every failed check is reported, joined, never a variable's value. func DistributeGuard(ctx context.Context, in GuardInput) error { @@ -135,9 +142,12 @@ func guardContextOf(cmd string, c hubch.Context) []error { } switch { case c.CI != hubch.GitHubActions && c.CI != hubch.GitLabCI && c.CI != hubch.GiteaActions && c.CI != hubch.ForgejoActions && - c.CI != hubch.BitbucketPipelines: + c.CI != hubch.BitbucketPipelines && c.CI != hubch.AzurePipelines: errs = append(errs, fmt.Errorf("%s cannot tell which ref this CI job builds: it runs in CI only on GitHub Actions, GitLab CI, Gitea Actions, "+ - "Forgejo Actions and Bitbucket Pipelines", cmd)) + "Forgejo Actions, Bitbucket Pipelines and Azure Pipelines", cmd)) + case c.CI == hubch.AzurePipelines && c.Fingerprint() == "": + errs = append(errs, fmt.Errorf("%s runs on Azure Pipelines only for a hub in Azure Repos of Azure DevOps Services: this build's repository "+ + "is elsewhere (Build.Repository.Provider), or its organization is not on dev.azure.com", cmd)) case !c.RefIsBranch: errs = append(errs, fmt.Errorf("%s runs only on the hub's default branch; this job builds %s, which is not a branch", cmd, guardRef(c.RefName))) case c.DefaultBranch != "" && c.RefName != c.DefaultBranch: @@ -145,6 +155,9 @@ func guardContextOf(cmd string, c hubch.Context) []error { case c.DefaultBranch == "" && c.CI == hubch.BitbucketPipelines: errs = append(errs, fmt.Errorf("%s cannot tell whether %s is the hub's default branch: Bitbucket Pipelines names none, and the hub "+ "channel could not read it; give the hub a repository access token with Repositories: Read in %s", cmd, c.RefName, hubch.BitbucketTokenVar)) + case c.DefaultBranch == "" && c.CI == hubch.AzurePipelines: + errs = append(errs, fmt.Errorf("%s cannot tell whether %s is the hub's default branch: Azure Pipelines names none, and the hub "+ + "channel could not read it; map the job access token into the step (env: %s: $(System.AccessToken))", cmd, c.RefName, hubch.AzureTokenVar)) case c.DefaultBranch == "" && c.Event != "schedule": errs = append(errs, fmt.Errorf("the CI names no default branch of the hub, so %s cannot tell whether %s is it", cmd, c.RefName)) } @@ -160,6 +173,10 @@ func guardContextOf(cmd string, c hubch.Context) []error { errs = append(errs, fmt.Errorf("%s needs a step with deployment: %s on Bitbucket Pipelines (BITBUCKET_DEPLOYMENT_ENVIRONMENT), "+ "whose deployment variables hold the write key", cmd, distributeEnvironment)) } + if c.CI == hubch.AzurePipelines && !strings.EqualFold(c.Environment, distributeEnvironment) { + errs = append(errs, fmt.Errorf("%s needs a deployment job to the environment %s on Azure Pipelines (ENVIRONMENT_NAME), "+ + "in the stage that links the variable group %s with the write key", cmd, distributeEnvironment, distributeEnvironment)) + } return errs } @@ -193,6 +210,10 @@ func guardIsolation(in GuardInput, getenv func(string) string) error { if in.Hub == nil || strings.TrimSpace(in.Hub.Security.Reason) == "" { return errors.New(BitbucketPlatformRefusal) } + case hubch.AzurePipelines: + if in.Hub == nil || strings.TrimSpace(in.Hub.Security.Reason) == "" { + return errors.New(AzurePlatformRefusal) + } } return nil } @@ -216,6 +237,25 @@ const BitbucketPlatformRefusal = "security.write_isolation: platform on Bitbucke "(Repository settings > Deployments) and state that in security.reason; on Free and Standard set write_isolation to none with a reason, " + "or to external; see " + docsurl.WriteIsolation +// AzurePlatformRefusal is why security.write_isolation platform without +// security.reason is refused on Azure Pipelines. +// +// Any branch's azure-pipelines.yml may link the variable group +// touchmark-distribute and so read the write key, unless the group's +// Branch control check admits the default branch alone (the stage of any +// other branch then fails its checks; +// https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals). +// A job's token is not known to read the checks (their API is a preview, +// and which role reads it is not documented), so touchmark cannot check +// them in the run: the hub states them in security.reason, reviewed like +// the rest of hub.yml, and doctor --hub-token reads them with a +// maintainer's token. The template's hub.yml does not state them, so a hub +// on Azure Pipelines is safe by default. +const AzurePlatformRefusal = "security.write_isolation: platform on Azure Pipelines needs the variable group " + distributeEnvironment + + " that holds the write key protected by a Branch control check that admits the default branch only, which touchmark cannot read " + + "from the job: add the check (Pipelines > Library > " + distributeEnvironment + " > Approvals and checks), state it in " + + "security.reason, and let doctor --hub-token verify it; or set write_isolation to external, or to none with a reason; see " + docsurl.WriteIsolation + // DistributeEnvironment is the environment of the hub's CI that holds the // write key and runs distribute. const DistributeEnvironment = distributeEnvironment diff --git a/internal/docsurl/docsurl_test.go b/internal/docsurl/docsurl_test.go index 75583cf..ee8764c 100644 --- a/internal/docsurl/docsurl_test.go +++ b/internal/docsurl/docsurl_test.go @@ -30,7 +30,8 @@ func slug(heading string) string { // and its anchor a heading of that page. func TestPagesExist(t *testing.T) { docs := filepath.Join("..", "..", "docs") - for _, url := range []string{WriteIsolation, Migrate, GiteaForgejo, GettingStarted("github"), GettingStarted("gitlab"), GettingStarted("bitbucket")} { + for _, url := range []string{WriteIsolation, Migrate, GiteaForgejo, GettingStarted("github"), GettingStarted("gitlab"), GettingStarted("bitbucket"), + GettingStarted("azure-devops")} { rest, ok := strings.CutPrefix(url, Base) if !ok { t.Errorf("%s is not under %s", url, Base) diff --git a/internal/e2e/azure/doc.go b/internal/e2e/azure/doc.go new file mode 100644 index 0000000..f72f721 --- /dev/null +++ b/internal/e2e/azure/doc.go @@ -0,0 +1,13 @@ +// Package azuree2e checks the hub template's Azure Pipelines files, +// azure-pipelines.yml and its step template .azure-pipelines/touchmark.yml +// of the template's working tree in TOUCHMARK_E2E_TEMPLATE, without Azure +// DevOps: TestTemplateAzurePipelines reads the stages and plays what Azure +// Pipelines gives each step (the predefined variables, the pipeline's +// secret variables a step maps, and the variable group's only to the jobs +// that link it), then runs touchmark's own guards on those steps. Like the +// GitHub package it has no build tag, and skips without +// TOUCHMARK_E2E_TEMPLATE or when the template has no azure-pipelines.yml. +// +// What only a live Azure DevOps can confirm is listed in +// docs/getting-started/azure-devops.md ("Assumed, not yet seen live"). +package azuree2e diff --git a/internal/e2e/azure/expr_test.go b/internal/e2e/azure/expr_test.go new file mode 100644 index 0000000..bbf92ca --- /dev/null +++ b/internal/e2e/azure/expr_test.go @@ -0,0 +1,195 @@ +package azuree2e + +import ( + "fmt" + "strings" + "testing" +) + +// evaluate evaluates a stage condition of Azure Pipelines in the subset the +// template uses (learn.microsoft.com/en-us/azure/devops/pipelines/process/expressions): +// and, or, not, eq, ne, in, succeeded(), 'string' literals and +// variables['Name'], read from env as the agent exports it (uppercased, '.' +// as '_'; unset is ""). Strings compare ignoring case, as Azure's do. +// succeeded is what succeeded() returns. +func evaluate(cond string, env map[string]string, succeeded bool) (bool, error) { + p := &parser{s: strings.TrimSpace(cond), env: env, succeeded: succeeded} + v, err := p.value() + if err != nil { + return false, err + } + p.space() + if p.i != len(p.s) { + return false, fmt.Errorf("trailing %q", p.s[p.i:]) + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("the condition is %q, not a boolean", v) + } + return b, nil +} + +type parser struct { + s string + i int + env map[string]string + succeeded bool +} + +func (p *parser) space() { + for p.i < len(p.s) && strings.ContainsRune(" \t\r\n", rune(p.s[p.i])) { + p.i++ + } +} + +func (p *parser) eat(c byte) error { + p.space() + if p.i >= len(p.s) || p.s[p.i] != c { + return fmt.Errorf("want %q at %d of %q", c, p.i, p.s) + } + p.i++ + return nil +} + +// value parses one value: a literal, a variable or a function call. +func (p *parser) value() (any, error) { + p.space() + if p.i >= len(p.s) { + return nil, fmt.Errorf("unexpected end of %q", p.s) + } + if p.s[p.i] == '\'' { + end := strings.IndexByte(p.s[p.i+1:], '\'') + if end < 0 { + return nil, fmt.Errorf("unterminated string in %q", p.s) + } + v := p.s[p.i+1 : p.i+1+end] + p.i += end + 2 + return v, nil + } + start := p.i + for p.i < len(p.s) && (p.s[p.i] >= 'a' && p.s[p.i] <= 'z' || p.s[p.i] >= 'A' && p.s[p.i] <= 'Z') { + p.i++ + } + name := p.s[start:p.i] + if name == "variables" { + if err := p.eat('['); err != nil { + return nil, err + } + key, err := p.value() + if err != nil { + return nil, err + } + if err := p.eat(']'); err != nil { + return nil, err + } + return p.env[strings.ToUpper(strings.ReplaceAll(fmt.Sprint(key), ".", "_"))], nil + } + if err := p.eat('('); err != nil { + return nil, err + } + var args []any + p.space() + if p.i < len(p.s) && p.s[p.i] == ')' { + p.i++ + } else { + for { + v, err := p.value() + if err != nil { + return nil, err + } + args = append(args, v) + p.space() + if p.i < len(p.s) && p.s[p.i] == ',' { + p.i++ + continue + } + if err := p.eat(')'); err != nil { + return nil, err + } + break + } + } + return apply(name, args, p.succeeded) +} + +// apply applies function name to args. +func apply(name string, args []any, succeeded bool) (any, error) { + bools := func() ([]bool, error) { + var out []bool + for _, a := range args { + b, ok := a.(bool) + if !ok { + return nil, fmt.Errorf("%s(%q): not a boolean", name, a) + } + out = append(out, b) + } + return out, nil + } + same := func(a, b any) bool { return strings.EqualFold(fmt.Sprint(a), fmt.Sprint(b)) } + switch name { + case "succeeded": + return succeeded, nil + case "and", "or": + bs, err := bools() + if err != nil { + return nil, err + } + if len(bs) < 2 { + return nil, fmt.Errorf("%s: %d arguments", name, len(bs)) + } + for _, b := range bs { + if b == (name == "or") { + return b, nil + } + } + return name == "and", nil + case "not": + bs, err := bools() + if err != nil { + return nil, err + } + if len(bs) != 1 { + return nil, fmt.Errorf("not: %d arguments", len(bs)) + } + return !bs[0], nil + case "eq", "ne": + if len(args) != 2 { + return nil, fmt.Errorf("%s: %d arguments", name, len(args)) + } + return same(args[0], args[1]) == (name == "eq"), nil + case "in": + if len(args) < 2 { + return nil, fmt.Errorf("in: %d arguments", len(args)) + } + for _, a := range args[1:] { + if same(args[0], a) { + return true, nil + } + } + return false, nil + } + return nil, fmt.Errorf("unknown function %q", name) +} + +func TestEvaluate(t *testing.T) { + env := map[string]string{"BUILD_REASON": "Manual", "BUILD_SOURCEBRANCH": "refs/heads/main"} + for cond, want := range map[string]bool{ + "succeeded()": true, + "and(succeeded(), eq(variables['Build.Reason'], 'manual'))": true, + "or(eq(variables['Build.Reason'], 'Schedule'), ne('a', 'A'))": false, + "in(variables['Build.SourceBranch'], 'refs/heads/master', 'refs/heads/main')": true, + "not(in(variables['Build.SourceBranch'], 'refs/heads/master'))": true, + "eq(variables['Build.CronSchedule.DisplayName'], '')": true, + "and(succeeded(),\n or(eq('x', 'y'), eq(variables['Build.Reason'], 'Manual')))": true, + } { + got, err := evaluate(cond, env, true) + if err != nil || got != want { + t.Errorf("%q = %v, %v; want %v", cond, got, err, want) + } + } + for _, bad := range []string{"eq('a')", "and(true)", "'a'", "eq('a', 'b') extra", "frob()"} { + if _, err := evaluate(bad, env, true); err == nil { + t.Errorf("%q: no error", bad) + } + } +} diff --git a/internal/e2e/azure/template_test.go b/internal/e2e/azure/template_test.go new file mode 100644 index 0000000..5f06bf3 --- /dev/null +++ b/internal/e2e/azure/template_test.go @@ -0,0 +1,496 @@ +package azuree2e + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + "go.yaml.in/yaml/v3" + + "github.com/bedrock-python/touchmark/internal/config" + "github.com/bedrock-python/touchmark/internal/distribute" + "github.com/bedrock-python/touchmark/internal/gitx" + "github.com/bedrock-python/touchmark/internal/hubch" +) + +// The files' shape, as far as the test reads it +// (learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/). +type pipelineFile struct { + Trigger any `yaml:"trigger"` + Schedules []schedule `yaml:"schedules"` + Parameters []struct { + Name string `yaml:"name"` + Default string `yaml:"default"` + Values []string `yaml:"values"` + } `yaml:"parameters"` + Variables any `yaml:"variables"` + Pool any `yaml:"pool"` + Container any `yaml:"container"` + Resources any `yaml:"resources"` + Stages []stage `yaml:"stages"` +} + +type schedule struct { + Cron string `yaml:"cron"` + DisplayName string `yaml:"displayName"` + Branches struct { + Include []string `yaml:"include"` + } `yaml:"branches"` + Always bool `yaml:"always"` +} + +type stage struct { + Stage string `yaml:"stage"` + DependsOn any `yaml:"dependsOn"` + Condition string `yaml:"condition"` + Variables any `yaml:"variables"` + Jobs []job `yaml:"jobs"` +} + +type job struct { + Job string `yaml:"job"` + Deployment string `yaml:"deployment"` + Environment any `yaml:"environment"` + Variables []map[string]any `yaml:"variables"` + Container any `yaml:"container"` + Pool any `yaml:"pool"` + Timeout int `yaml:"timeoutInMinutes"` + Steps []map[string]any `yaml:"steps"` + Strategy struct { + RunOnce struct { + Deploy struct { + Steps []map[string]any `yaml:"steps"` + } `yaml:"deploy"` + } `yaml:"runOnce"` + } `yaml:"strategy"` +} + +// stepTemplate is .azure-pipelines/touchmark.yml. +type stepTemplate struct { + Parameters []struct { + Name string `yaml:"name"` + } `yaml:"parameters"` + Steps []map[string]any `yaml:"steps"` +} + +// call is one use of the step template: its parameters. +type call struct { + name, args string + write, report bool +} + +const ( + stepFile = ".azure-pipelines/touchmark.yml" + repoID = "0b7e5a2c-9d4f-4e1b-8a3c-6f5d2e1c0b9a" + readTok = "reader-pat-0123456789" + writeTok = "writer-pat-0123456789" + accessTok = "job-access-token-0123456789" +) + +var ( + imageRe = regexp.MustCompile(`ghcr\.io/bedrock-python/touchmark:[^@\s]+@sha256:[0-9a-f]{64}`) + commandRe = regexp.MustCompile(`^(probe|check|plan|distribute|doctor)\b`) + deadlineRe = regexp.MustCompile(`--deadline (\d+)m\b`) +) + +// TestTemplateAzurePipelines checks the security properties of the +// template's azure-pipelines.yml and its step template: +// +// - every touchmark step runs the touchmark image pinned by digest, from +// the one step template, with docker as the agent's user; no job runs +// a container of its own; every checkout reads the whole history; +// - the stages: probe first, without the variable group; check and plan +// --strict --comment for a pull request; distribute and doctor as +// deployment jobs to the environment touchmark-distribute that link the +// variable group touchmark-distribute, which no other stage or job +// links; no variables at the pipeline's top; +// - the step template maps the writer's token only for write: true, and +// the pipeline asks for it only in the probe and the deployment jobs; +// - played with the variables Azure Pipelines gives each step (the +// pipeline's secrets to every step that maps them, the group's to the +// jobs that link it) for a pull request, a push, both schedules and +// both manual runs, evaluating the stages' conditions: the probe +// passes, the expected stages run, and touchmark's guards admit +// distribute and doctor on the default branch of a hub that states its +// Branch control check, and refuse them under platform without the +// statement; with the write key leaked into a pipeline variable, every +// run stops at its probe, before any stage that holds it. +func TestTemplateAzurePipelines(t *testing.T) { + dir := os.Getenv("TOUCHMARK_E2E_TEMPLATE") + if dir == "" { + t.Skip("TOUCHMARK_E2E_TEMPLATE is not set: it names the hub template's working tree") + } + data, err := os.ReadFile(filepath.Join(dir, "azure-pipelines.yml")) + if errors.Is(err, os.ErrNotExist) { + t.Skip("the template has no azure-pipelines.yml") + } + if err != nil { + t.Fatal(err) + } + var f pipelineFile + if err := yaml.Unmarshal(data, &f); err != nil { + t.Fatalf("azure-pipelines.yml: %v", err) + } + tmplData, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(stepFile))) + if err != nil { + t.Fatal(err) + } + var tmpl stepTemplate + if err := yaml.Unmarshal(tmplData, &tmpl); err != nil { + t.Fatalf("%s: %v", stepFile, err) + } + checkStepTemplate(t, tmpl) + if f.Variables != nil || f.Container != nil || f.Resources != nil { + t.Error("the pipeline sets variables, a container or resources at its top: every stage would get them") + } + if len(f.Parameters) != 1 || f.Parameters[0].Name != "run" || f.Parameters[0].Default != "distribute" || + !slices.Equal(f.Parameters[0].Values, []string{"distribute", "doctor"}) { + t.Errorf("parameters %+v: want run, distribute or doctor", f.Parameters) + } + var crons []string + for _, s := range f.Schedules { + crons = append(crons, s.DisplayName) + if !s.Always || !slices.Contains(s.Branches.Include, "main") || !slices.Contains(s.Branches.Include, "master") { + t.Errorf("schedule %q: want always, on main and master", s.DisplayName) + } + } + if !slices.Equal(crons, []string{"touchmark distribute", "touchmark doctor"}) { + t.Errorf("schedules %q", crons) + } + + stages := map[string]stage{} + var order []string + for _, s := range f.Stages { + stages[s.Stage] = s + order = append(order, s.Stage) + } + if !slices.Equal(order, []string{"probe", "check", "distribute", "doctor"}) { + t.Fatalf("stages %q", order) + } + calls := map[string][]call{} + for _, s := range f.Stages { + if s.Variables != nil { + t.Errorf("stage %s sets variables", s.Stage) + } + if s.Stage != "probe" && fmt.Sprint(s.DependsOn) != "probe" { + t.Errorf("stage %s depends on %v, want probe", s.Stage, s.DependsOn) + } + for _, j := range s.Jobs { + calls[s.Stage] = append(calls[s.Stage], checkJob(t, s, j)...) + } + } + want := map[string][]string{"probe": {"probe"}, "check": {"check", "plan"}, "distribute": {"distribute"}, "doctor": {"doctor"}} + for name, cs := range calls { + var got []string + for _, c := range cs { + got = append(got, c.name) + } + if !slices.Equal(got, want[name]) { + t.Errorf("stage %s runs %q, want %q", name, got, want[name]) + } + } + if t.Failed() { + return + } + + checked := &config.Hub{ID: "acme-eng", Security: config.Security{WriteIsolation: "platform", + Reason: "a Branch control check admits refs/heads/main only to the variable group touchmark-distribute"}} + pipelineVars := map[string]string{"TOUCHMARK_READ_TOKEN": readTok} + groupVars := map[string]string{"TOUCHMARK_WRITE_TOKEN": writeTok} + runs := []struct { + name string + env map[string]string + run string + stages []string + }{ + {"pull request", map[string]string{"BUILD_REASON": "PullRequest", "BUILD_SOURCEBRANCH": "refs/pull/41/merge", + "SYSTEM_PULLREQUEST_SOURCEBRANCH": "refs/heads/feature", "SYSTEM_PULLREQUEST_TARGETBRANCH": "refs/heads/main", + "SYSTEM_PULLREQUEST_PULLREQUESTID": "41"}, "distribute", []string{"probe", "check"}}, + {"push", map[string]string{"BUILD_REASON": "IndividualCI", "BUILD_SOURCEBRANCH": "refs/heads/main"}, "distribute", []string{"probe", "distribute"}}, + {"batched push", map[string]string{"BUILD_REASON": "BatchedCI", "BUILD_SOURCEBRANCH": "refs/heads/main"}, "distribute", []string{"probe", "distribute"}}, + {"daily schedule", map[string]string{"BUILD_REASON": "Schedule", "BUILD_SOURCEBRANCH": "refs/heads/main", + "BUILD_CRONSCHEDULE_DISPLAYNAME": "touchmark distribute"}, "distribute", []string{"probe", "distribute"}}, + {"weekly schedule", map[string]string{"BUILD_REASON": "Schedule", "BUILD_SOURCEBRANCH": "refs/heads/main", + "BUILD_CRONSCHEDULE_DISPLAYNAME": "touchmark doctor"}, "distribute", []string{"probe", "doctor"}}, + {"manual distribute", map[string]string{"BUILD_REASON": "Manual", "BUILD_SOURCEBRANCH": "refs/heads/main"}, "distribute", []string{"probe", "distribute"}}, + {"manual doctor", map[string]string{"BUILD_REASON": "Manual", "BUILD_SOURCEBRANCH": "refs/heads/main"}, "doctor", []string{"probe", "doctor"}}, + {"manual run of another branch", map[string]string{"BUILD_REASON": "Manual", "BUILD_SOURCEBRANCH": "refs/heads/feature"}, "distribute", []string{"probe"}}, + {"push of a tag", map[string]string{"BUILD_REASON": "IndividualCI", "BUILD_SOURCEBRANCH": "refs/tags/v1"}, "distribute", []string{"probe"}}, + } + for _, r := range runs { + // As configured: the stages run, and every step passes. + ran := play(t, f, calls, r.env, r.run, pipelineVars, groupVars, checked, "") + if !slices.Equal(ran, r.stages) { + t.Errorf("%s: ran %q, want %q", r.name, ran, r.stages) + } + // The write key leaked into a pipeline variable: the probe stops it. + leaked := map[string]string{"TOUCHMARK_READ_TOKEN": readTok, "TOUCHMARK_WRITE_TOKEN": writeTok} + if ran := play(t, f, calls, r.env, r.run, leaked, groupVars, checked, ""); len(ran) != 0 { + t.Errorf("%s with a leaked write key ran %q", r.name, ran) + } + } + // The template's hub.yml: platform without the statement is refused. + plain := &config.Hub{ID: "acme-eng", Security: config.Security{WriteIsolation: "platform"}} + play(t, f, calls, runs[1].env, "distribute", pipelineVars, groupVars, plain, "Branch control check") + play(t, f, calls, runs[6].env, "doctor", pipelineVars, groupVars, plain, "Branch control check") +} + +// checkStepTemplate checks .azure-pipelines/touchmark.yml: one bash step +// that runs the pinned image with docker as the agent's user, mapping the +// writer's token only under write, and the publish of the reports. +func checkStepTemplate(t *testing.T, tmpl stepTemplate) { + t.Helper() + var names []string + for _, p := range tmpl.Parameters { + names = append(names, p.Name) + } + if !slices.Equal(names, []string{"name", "args", "write", "report"}) { + t.Errorf("%s parameters %q", stepFile, names) + } + if len(tmpl.Steps) != 2 { + t.Fatalf("%s: %d steps, want the touchmark step and the conditional publish", stepFile, len(tmpl.Steps)) + } + script, _ := tmpl.Steps[0]["bash"].(string) + if n := len(imageRe.FindAllString(script, -1)); n != 1 || strings.Count(script, "ghcr.io/") != 1 { + t.Errorf("%s: want the touchmark image pinned by digest, once", stepFile) + } + for _, want := range []string{"docker run", `--user "$(id -u):$(id -g)"`, "--cap-drop ALL", "--security-opt no-new-privileges", "${{ parameters.args }}"} { + if !strings.Contains(script, want) { + t.Errorf("%s: the script lacks %q", stepFile, want) + } + } + for _, bad := range []string{"docker.sock", "--privileged", "$(TOUCHMARK", "--env-file"} { + if strings.Contains(script, bad) { + t.Errorf("%s: the script has %q", stepFile, bad) + } + } + env, _ := tmpl.Steps[0]["env"].(map[string]any) + got := map[string]any{} + for k, v := range env { + got[k] = v + } + write, _ := got["${{ if parameters.write }}"].(map[string]any) + delete(got, "${{ if parameters.write }}") + if fmt.Sprint(got) != fmt.Sprint(map[string]any{"SYSTEM_ACCESSTOKEN": "$(System.AccessToken)", "TOUCHMARK_READ_TOKEN": "$(TOUCHMARK_READ_TOKEN)"}) || + fmt.Sprint(write) != fmt.Sprint(map[string]any{"TOUCHMARK_WRITE_TOKEN": "$(TOUCHMARK_WRITE_TOKEN)"}) { + t.Errorf("%s: env %v", stepFile, env) + } + if _, ok := tmpl.Steps[1]["${{ if parameters.report }}"]; !ok { + t.Errorf("%s: the second step is not the publish under report", stepFile) + } +} + +// checkJob checks one job of stage s and returns its uses of the step +// template. +func checkJob(t *testing.T, s stage, j job) []call { + t.Helper() + name := j.Job + j.Deployment + if j.Container != nil || j.Pool != nil { + t.Errorf("%s/%s sets its own container or pool", s.Stage, name) + } + steps := j.Steps + deploy := j.Deployment != "" + if deploy { + steps = j.Strategy.RunOnce.Deploy.Steps + } + var groups []string + for _, v := range j.Variables { + if g, ok := v["group"].(string); ok { + groups = append(groups, g) + } else { + t.Errorf("%s/%s sets a variable: %v", s.Stage, name, v) + } + } + var calls []call + for _, st := range steps { + switch { + case st["checkout"] != nil: + if st["checkout"] == "self" && fmt.Sprint(st["fetchDepth"]) != "0" { + t.Errorf("%s/%s: a shallow checkout", s.Stage, name) + } + case st["template"] != nil: + if st["template"] != stepFile { + t.Errorf("%s/%s uses template %v", s.Stage, name, st["template"]) + continue + } + p, _ := st["parameters"].(map[string]any) + c := call{name: fmt.Sprint(p["name"]), args: fmt.Sprint(p["args"]), write: p["write"] == true, report: p["report"] == true} + calls = append(calls, c) + m := commandRe.FindStringSubmatch(c.args) + if m == nil || m[1] != c.name { + t.Errorf("%s/%s: step %s runs %q", s.Stage, name, c.name, c.args) + } + case st["bash"] != nil: + script := fmt.Sprint(st["bash"]) + if strings.Contains(script, "TOUCHMARK_") || strings.Contains(script, "touchmark ") || s.Stage != "check" { + t.Errorf("%s/%s: a script step of its own: %s", s.Stage, name, script) + } + default: + t.Errorf("%s/%s: an unexpected step %v", s.Stage, name, st) + } + } + keyed := s.Stage == "distribute" || s.Stage == "doctor" + for _, c := range calls { + switch { + case keyed: + env, _ := j.Environment.(string) + if !deploy || env != "touchmark-distribute" || !slices.Equal(groups, []string{"touchmark-distribute"}) { + t.Errorf("%s/%s: want a deployment job to touchmark-distribute that links the group touchmark-distribute", s.Stage, name) + } + if !c.write || !c.report { + t.Errorf("%s/%s: %s without write or report", s.Stage, name, c.name) + } + case len(groups) > 0 || deploy: + t.Errorf("%s/%s: %s runs with the variable group or in a deployment job", s.Stage, name, c.name) + case c.write && c.name != "probe": + t.Errorf("%s/%s: %s maps the writer's token", s.Stage, name, c.name) + } + if c.name == "plan" && (!strings.Contains(c.args, "--strict") || !strings.Contains(c.args, "--comment")) { + t.Errorf("plan without --strict --comment") + } + if c.name == "distribute" { + m := deadlineRe.FindStringSubmatch(c.args) + if d, _ := strconv.Atoi(func() string { + if m == nil { + return "" + } + return m[1] + }()); m == nil || d+5 > j.Timeout { + t.Errorf("distribute needs a deadline well under its timeout (%d minutes): %q", j.Timeout, c.args) + } + } + } + return calls +} + +// play runs the pipeline as Azure Pipelines would for a run with env (the +// reason and branch) and the parameter run, stage by stage: a stage runs +// when its condition holds; its steps get the predefined variables, the +// job access token, the pipeline's variables pipelineVars and, in a job +// that links the group, groupVars, as far as the step template maps them. +// The probe goes through distribute.Probe, distribute and doctor through +// touchmark's guards (with the default branch main, as the hub channel +// reads it), check and plan through the probe's eyes (no write key). It +// returns the stages that ran to the end; wantErr, when set, is part of the +// error that must stop one. +func play(t *testing.T, f pipelineFile, calls map[string][]call, env map[string]string, run string, + pipelineVars, groupVars map[string]string, hub *config.Hub, wantErr string) []string { + t.Helper() + var ran []string + failed := false + for _, s := range f.Stages { + if s.Stage != "probe" { + cond := strings.ReplaceAll(s.Condition, "${{ parameters.run }}", run) + ok, err := evaluate(cond, env, !failed) + if err != nil { + t.Fatalf("stage %s: condition %q: %v", s.Stage, s.Condition, err) + } + if !ok { + continue + } + } + keyed := s.Stage == "distribute" || s.Stage == "doctor" + for _, c := range calls[s.Stage] { + step := map[string]string{ + "CI": "true", "TF_BUILD": "True", "SYSTEM_COLLECTIONURI": "https://dev.azure.com/acme/", "SYSTEM_TEAMPROJECT": "Platform", + "BUILD_REPOSITORY_PROVIDER": "TfsGit", "BUILD_REPOSITORY_ID": repoID, "BUILD_REPOSITORY_NAME": "engineering-assets", + hubch.AzureTokenVar: accessTok, + } + for k, v := range env { + step[k] = v + } + scope := map[string]string{} + for k, v := range pipelineVars { + scope[k] = v + } + if keyed { + step["ENVIRONMENT_NAME"] = "touchmark-distribute" + for k, v := range groupVars { + scope[k] = v + } + } + // The step template maps the reader's token, and the writer's + // under write; an undefined one is left out. + mapped := []string{"TOUCHMARK_READ_TOKEN"} + if c.write { + mapped = append(mapped, "TOUCHMARK_WRITE_TOKEN") + } + for _, k := range mapped { + if v, ok := scope[k]; ok { + step[k] = v + } + } + getenv := func(k string) string { return step[k] } + environ := func() []string { + var out []string + for k, v := range step { + out = append(out, k+"="+v) + } + return out + } + var err error + switch c.name { + case "distribute", "doctor": + hctx := hubch.Detect(getenv, nil) + if hctx.CI != hubch.AzurePipelines || hctx.Fingerprint() != "dev.azure.com/"+repoID { + t.Fatalf("%s: the step's context %+v", s.Stage, hctx) + } + hctx.DefaultBranch = "main" + if c.name == "doctor" { + err = distribute.DoctorGuard(hctx, getenv) + if err == nil { + err = isolationFails(hctx, hub) + } + } else { + err = distribute.DistributeGuard(t.Context(), distribute.GuardInput{Context: hctx, Hub: hub, Getenv: getenv, GitVersion: gitx.DeliveryMinVersion}) + } + default: + if res := distribute.Probe(getenv, environ); len(res.Exposed) > 0 { + err = errors.New("probe: " + c.name + " sees " + strings.Join(res.Exposed, ", ")) + } + } + if err != nil { + if wantErr != "" && !strings.Contains(err.Error(), wantErr) { + t.Errorf("stage %s: step %s: %v, want %q", s.Stage, c.name, err, wantErr) + } + switch { + case wantErr != "": + case !strings.HasPrefix(err.Error(), "probe: "): + t.Errorf("stage %s: step %s: %v", s.Stage, c.name, err) + case c.name != "probe": + t.Errorf("stage %s: step %s sees a write key the probe did not", s.Stage, c.name) + } + failed = true + break + } + } + if failed { + if s.Stage == "probe" { + return ran + } + continue + } + ran = append(ran, s.Stage) + } + if wantErr != "" && !failed { + t.Errorf("every stage ran, want %q", wantErr) + } + return ran +} + +// isolationFails is doctor's write-isolation check as an error when it +// fails: doctor reports it, and exits 1. +func isolationFails(hctx hubch.Context, hub *config.Hub) error { + for _, c := range distribute.IsolationChecks(distribute.IsolationInput{Context: hctx, Hub: hub}) { + if c.Status == "fail" { + return errors.New(c.Detail) + } + } + return nil +} diff --git a/internal/hubch/azure.go b/internal/hubch/azure.go new file mode 100644 index 0000000..3739c1e --- /dev/null +++ b/internal/hubch/azure.go @@ -0,0 +1,377 @@ +package hubch + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/bedrock-python/touchmark/internal/config" + "github.com/bedrock-python/touchmark/internal/httpx" +) + +// A hub in Azure Repos (Azure DevOps Services), run by Azure Pipelines. +// Sources: +// - the predefined variables: +// https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables +// - the job access token (System.AccessToken): +// https://learn.microsoft.com/en-us/azure/devops/pipelines/process/access-tokens +// - the REST API 7.1: https://learn.microsoft.com/en-us/rest/api/azure/devops/ +// (Git: Repositories, Refs, Pull Request Threads; Core: connectionData). + +// AzureTokenVar names the job access token of Azure Pipelines in a step's +// environment. Azure Pipelines hands System.AccessToken to a script only +// when the step maps it (env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)); +// it acts as the pipeline's build service identity, and with it the hub +// channel reads the hub's default branch, its tip and visibility, and keeps +// plan's comment in a hub pull request. It is the counterpart of +// GITHUB_TOKEN and CI_JOB_TOKEN. +const AzureTokenVar = "SYSTEM_ACCESSTOKEN" + +// The host of Azure DevOps Services, the only home touchmark supports for a +// hub on Azure Pipelines: every organization is https://dev.azure.com/. +const azureHost = "dev.azure.com" + +// AzureRepoID returns an Azure Repos repository id (a GUID) in the form +// the hub fingerprint carries it: lowercase, without braces, so that the +// fingerprint is dev.azure.com/0f1e…. Anything that is not a GUID gives "". +// Repository ids are GUIDs unique across organizations, so the fingerprint +// needs no organization. +func AzureRepoID(s string) string { return BitbucketRepoID(s) } + +// detectAzure reads Azure Pipelines' predefined variables (TF_BUILD=True): +// - only a build of an Azure Repos Git repository (Build.Repository.Provider +// TfsGit) of Azure DevOps Services (System.CollectionUri on dev.azure.com +// or .visualstudio.com) has a hub: ServerURL and APIURL are +// https://dev.azure.com/, Host dev.azure.com, RepoID +// Build.Repository.ID in canonical form (AzureRepoID), RepoPath +// /. Anything else leaves +// them empty, so no fingerprint, and distribute refuses; +// - Build.Reason PullRequest (a build validation policy), or a +// Build.SourceBranch under refs/pull/, is Event pull_request, with +// RefName the pull request's source branch and no branch run; +// IndividualCI and BatchedCI are push, Schedule schedule, Manual +// manual, any other reason lowercased. A Build.SourceBranch under +// refs/heads/ is a branch run of that branch, one under refs/tags/ is +// not; +// - Environment is Environment.Name, set in a deployment job; +// - DefaultBranch and Visibility stay empty: no variable names them, and +// the hub channel reads them (RepositoryReader). +func detectAzure(env func(string) string) Context { + c := Context{CI: AzurePipelines} + if !strings.EqualFold(env("BUILD_REPOSITORY_PROVIDER"), "TfsGit") { + return c + } + collection := env("SYSTEM_COLLECTIONURI") + if collection == "" { + collection = env("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI") + } + if c.ServerURL = config.AzureCollectionURL(collection); c.ServerURL != "" { + c.APIURL, c.Host = c.ServerURL, azureHost + } + c.RepoID = AzureRepoID(env("BUILD_REPOSITORY_ID")) + if project, name := env("SYSTEM_TEAMPROJECT"), env("BUILD_REPOSITORY_NAME"); project != "" && name != "" { + c.RepoPath = project + "/" + name + } + source := env("BUILD_SOURCEBRANCH") + reason := env("BUILD_REASON") + switch { + case strings.EqualFold(reason, "PullRequest") || strings.HasPrefix(source, "refs/pull/"): + c.Event = "pull_request" + c.RefName = strings.TrimPrefix(env("SYSTEM_PULLREQUEST_SOURCEBRANCH"), "refs/heads/") + case strings.HasPrefix(source, "refs/heads/"): + c.RefName, c.RefIsBranch = strings.TrimPrefix(source, "refs/heads/"), true + case strings.HasPrefix(source, "refs/tags/"): + c.RefName = strings.TrimPrefix(source, "refs/tags/") + } + if c.Event == "" { + switch strings.ToLower(reason) { + case "individualci", "batchedci": + c.Event = "push" + case "": + default: + c.Event = strings.ToLower(reason) + } + } + c.Environment = env("ENVIRONMENT_NAME") + c.RepositoryURL = cleanURL(env("BUILD_REPOSITORY_URI")) + return c +} + +// azureChannel reads the hub repository through the REST API of Azure +// DevOps Services with the job access token, and keeps plan's comment in a +// hub pull request. +type azureChannel struct { + client *httpx.Client + auth *httpx.Auth // nil without a token + api string // https://dev.azure.com/ + repo string // {api}/_apis/git/repositories/{id} + id string // the repository id, canonical + branch string // the default branch, "" until read + info *RepoInfo // the repository, once read +} + +// The REST API versions the channel asks for: 7.1, and its preview for +// connectionData, which has no other. +const ( + azureAPIVersion = "7.1" + azurePreviewVersion = "7.1-preview.1" +) + +// newAzure returns the channel of a hub in Azure Repos: the repository +// c.RepoID of the organization at c.APIURL, with token, when set, sent as a +// Bearer token to the API's host only. When c names its default branch, +// Head reads that branch; else the repository's default branch. +func newAzure(c Context, client *httpx.Client, token string) (Channel, error) { + if c.DefaultBranch != "" { + if err := checkBranch(c.DefaultBranch); err != nil { + return nil, fmt.Errorf("hub channel: default branch: %w", err) + } + } + if AzureRepoID(c.RepoID) == "" { + return nil, errors.New("hub channel: the hub's repository id is unknown: Azure Pipelines builds no Azure Repos Git repository here") + } + api, err := url.Parse(c.APIURL) + if err != nil || api.Host == "" || api.User != nil || (api.Scheme != "https" && api.Scheme != "http") { + return nil, errors.New("hub channel: the API URL is not an absolute http(s) URL without credentials") + } + api.RawQuery, api.ForceQuery, api.Fragment, api.RawFragment = "", false, "", "" + if client == nil { + client = httpx.New(httpx.Options{}) + } + base := strings.TrimRight(api.String(), "/") + ch := &azureChannel{ + client: client, + api: base, + repo: base + "/_apis/git/repositories/" + AzureRepoID(c.RepoID), + id: AzureRepoID(c.RepoID), + branch: c.DefaultBranch, + } + if token != "" { + if err := checkToken(token); err != nil { + return nil, err + } + value := "Bearer " + token + ch.auth = &httpx.Auth{Hosts: []string{api.Host}, Header: func(context.Context) (string, error) { return value, nil }} + } + return ch, nil +} + +// azureHeaders are sent with every request: JSON, and a 401 instead of a +// redirect to the sign-in page for a refused token. +func azureHeaders() http.Header { + return http.Header{"Accept": {"application/json"}, "X-Tfs-Fedauthredirect": {"Suppress"}} +} + +// do sends one request of the channel with api-version and query. +func (ch *azureChannel) do(ctx context.Context, method, u string, query url.Values, in, out any) error { + return ch.doVersion(ctx, azureAPIVersion, method, u, query, in, out) +} + +// doVersion is do with another api-version. +func (ch *azureChannel) doVersion(ctx context.Context, version, method, u string, query url.Values, in, out any) error { + q := url.Values{} + for k, v := range query { + q[k] = v + } + q.Set("api-version", version) + resp, err := ch.client.JSONWith(ctx, method, u+"?"+q.Encode(), ch.auth, azureHeaders(), in, out) + if resp != nil && (resp.Status == http.StatusNonAuthoritativeInfo || resp.Status/100 == 3) { + return fmt.Errorf("HTTP %d: Azure DevOps sent the request to its sign-in page; the job access token is missing or refused "+ + "(map it into the step: env: %s: $(System.AccessToken))", resp.Status, AzureTokenVar) + } + return err +} + +// Repository reads the hub repository (GET +// {org}/_apis/git/repositories/{id}): its default branch (refs/heads/…) +// and its project's visibility, which the answer's project may leave out +// (the reference's sample does): then the project itself (GET +// {org}/_apis/projects/{project id}). The answer must name the repository +// the CI named, and a default branch. +func (ch *azureChannel) Repository(ctx context.Context) (RepoInfo, error) { + if ch.info != nil { + return *ch.info, nil + } + var repo struct { + ID string `json:"id"` + DefaultBranch string `json:"defaultBranch"` + Project struct { + ID string `json:"id"` + Visibility string `json:"visibility"` + } `json:"project"` + } + if err := ch.do(ctx, http.MethodGet, ch.repo, nil, nil, &repo); err != nil { + return RepoInfo{}, fmt.Errorf("hub channel: read the hub repository: %w", err) + } + if AzureRepoID(repo.ID) != ch.id { + return RepoInfo{}, fmt.Errorf("hub channel: the answer names repository %q, not %s", repo.ID, ch.id) + } + branch, ok := strings.CutPrefix(repo.DefaultBranch, "refs/heads/") + if !ok || checkBranch(branch) != nil { + return RepoInfo{}, errors.New("hub channel: the hub repository's answer names no default branch") + } + info := RepoInfo{DefaultBranch: branch} + vis := repo.Project.Visibility + if vis == "" { + project := AzureRepoID(repo.Project.ID) + if project == "" { + return RepoInfo{}, errors.New("hub channel: the hub repository's answer names no project") + } + var p struct { + ID string `json:"id"` + Visibility string `json:"visibility"` + } + if err := ch.do(ctx, http.MethodGet, ch.api+"/_apis/projects/"+project, nil, nil, &p); err != nil { + return RepoInfo{}, fmt.Errorf("hub channel: read the hub's project: %w", err) + } + if AzureRepoID(p.ID) != project { + return RepoInfo{}, fmt.Errorf("hub channel: the answer names project %q, not %s", p.ID, project) + } + vis = p.Visibility + } + switch v := strings.ToLower(vis); v { + case "private", "public": + info.Visibility = v + default: + return RepoInfo{}, fmt.Errorf("hub channel: the hub project's visibility %q is neither private nor public", vis) + } + ch.info = &info + return info, nil +} + +// Visibility reads the hub's visibility, its project's (VisibilityReader). +func (ch *azureChannel) Visibility(ctx context.Context) (string, error) { + info, err := ch.Repository(ctx) + return info.Visibility, err +} + +// Head reads the tip of the default branch: GET +// …/repositories/{id}/refs?filter=heads/{branch}, a prefix filter whose +// exact match refs/heads/{branch} carries the commit id. +func (ch *azureChannel) Head(ctx context.Context) (string, error) { + branch := ch.branch + if branch == "" { + info, err := ch.Repository(ctx) + if err != nil { + return "", err + } + branch = info.DefaultBranch + } + var refs struct { + Value []struct { + Name string `json:"name"` + ObjectID string `json:"objectId"` + } `json:"value"` + } + if err := ch.do(ctx, http.MethodGet, ch.repo+"/refs", url.Values{"filter": {"heads/" + branch}}, nil, &refs); err != nil { + return "", fmt.Errorf("hub channel: read the tip of branch %q: %w", branch, err) + } + for _, r := range refs.Value { + if r.Name != "refs/heads/"+branch { + continue + } + sha := strings.ToLower(r.ObjectID) + if !isOID(sha) { + return "", fmt.Errorf("hub channel: unexpected answer for branch %q: commit %q", branch, r.ObjectID) + } + return sha, nil + } + return "", fmt.Errorf("hub channel: branch %q not found", branch) +} + +// self returns the id of the identity the token acts as (GET +// {org}/_apis/connectionData, authenticatedUser.id, as Microsoft's own API +// clients read it; the endpoint is not in the REST reference): the +// pipeline's build service. +func (ch *azureChannel) self(ctx context.Context) (string, error) { + var data struct { + AuthenticatedUser struct { + ID string `json:"id"` + } `json:"authenticatedUser"` + } + if err := ch.doVersion(ctx, azurePreviewVersion, http.MethodGet, ch.api+"/_apis/connectionData", nil, nil, &data); err != nil { + return "", fmt.Errorf("hub channel: read the token's identity: %w", err) + } + id := strings.ToLower(strings.TrimSpace(data.AuthenticatedUser.ID)) + if id == "" || id == "00000000-0000-0000-0000-000000000000" { + return "", errors.New("hub channel: the token's identity is anonymous: map the job access token into the step " + + "(env: " + AzureTokenVar + ": $(System.AccessToken))") + } + return id, nil +} + +// azureThreadClosed is the status of the thread touchmark creates: closed, +// so that a policy requiring comment resolution is not held by it. +const azureThreadClosed = "closed" + +// UpsertComment keeps plan's comment in hub pull request pr (Commenter): +// it learns the token's identity (connectionData), lists the pull +// request's threads (GET …/pullRequests/{pr}/threads), and takes the first +// comment, not deleted, by that identity whose content holds marker; when +// its content differs it replaces it (PATCH +// …/threads/{thread}/comments/{comment}). Without one it creates a closed +// thread with the comment (POST …/threads), so that a policy requiring +// comment resolution does not wait on it. A comment of anyone else that +// holds the marker is never changed. +func (ch *azureChannel) UpsertComment(ctx context.Context, pr int64, marker, body string) (CommentResult, error) { + if pr <= 0 { + return "", fmt.Errorf("hub channel: no pull request to comment on (#%d)", pr) + } + if marker == "" || !strings.Contains(body, marker) { + return "", errors.New("hub channel: the comment's body lacks its marker") + } + self, err := ch.self(ctx) + if err != nil { + return "", err + } + threads := ch.repo + "/pullRequests/" + strconv.FormatInt(pr, 10) + "/threads" + var list struct { + Value []struct { + ID int64 `json:"id"` + IsDeleted bool `json:"isDeleted"` + Comments []struct { + ID int64 `json:"id"` + Content string `json:"content"` + IsDeleted bool `json:"isDeleted"` + CommentType string `json:"commentType"` + Author struct { + ID string `json:"id"` + } `json:"author"` + } `json:"comments"` + } `json:"value"` + } + if err := ch.do(ctx, http.MethodGet, threads, nil, nil, &list); err != nil { + return "", fmt.Errorf("hub channel: list the comment threads of #%d: %w", pr, err) + } + for _, t := range list.Value { + if t.IsDeleted || t.ID <= 0 { + continue + } + for _, c := range t.Comments { + if c.IsDeleted || c.ID <= 0 || !strings.EqualFold(c.Author.ID, self) || !strings.Contains(c.Content, marker) || + (c.CommentType != "" && !strings.EqualFold(c.CommentType, "text")) { + continue + } + if c.Content == body { + return CommentUnchanged, nil + } + edit := threads + "/" + strconv.FormatInt(t.ID, 10) + "/comments/" + strconv.FormatInt(c.ID, 10) + if err := ch.do(ctx, http.MethodPatch, edit, nil, map[string]string{"content": body}, nil); err != nil { + return "", fmt.Errorf("hub channel: update comment %d of thread %d of #%d: %w", c.ID, t.ID, pr, err) + } + return CommentUpdated, nil + } + } + thread := map[string]any{ + "comments": []map[string]any{{"parentCommentId": 0, "content": body, "commentType": "text"}}, + "status": azureThreadClosed, + } + if err := ch.do(ctx, http.MethodPost, threads, nil, thread, nil); err != nil { + return "", fmt.Errorf("hub channel: comment on #%d: %w", pr, err) + } + return CommentCreated, nil +} diff --git a/internal/hubch/azure_test.go b/internal/hubch/azure_test.go new file mode 100644 index 0000000..7fc015b --- /dev/null +++ b/internal/hubch/azure_test.go @@ -0,0 +1,336 @@ +package hubch + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + + "github.com/bedrock-python/touchmark/internal/httpx" +) + +// The hub repository on Azure DevOps, as Build.Repository.ID gives its id, +// and as the fingerprint carries it. +const ( + azRepo = "0B7E5A2C-9D4F-4E1B-8A3C-6F5D2E1C0B9A" + azID = "0b7e5a2c-9d4f-4e1b-8a3c-6f5d2e1c0b9a" + azProject = "7d1e3c5a-2b4f-4a6e-9c8d-1f0e2d3c4b5a" + azSelf = "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9" + azTok = "az-job-access-token-0123456789" +) + +// azureEnv is a step of Azure Pipelines building the hub (the predefined +// variables of +// https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables), +// with extra variables over it ("" removes one). +func azureEnv(extra map[string]string) func(string) string { + vars := map[string]string{ + "TF_BUILD": "True", + "SYSTEM_COLLECTIONURI": "https://dev.azure.com/acme/", + "SYSTEM_TEAMPROJECT": "Platform", + "SYSTEM_TEAMPROJECTID": azProject, + "BUILD_REPOSITORY_PROVIDER": "TfsGit", + "BUILD_REPOSITORY_ID": azRepo, + "BUILD_REPOSITORY_NAME": "engineering-assets", + "BUILD_REPOSITORY_URI": "https://acme@dev.azure.com/acme/Platform/_git/engineering-assets", + } + for k, v := range extra { + if v == "" { + delete(vars, k) + } else { + vars[k] = v + } + } + return envOf(vars) +} + +func TestDetectAzure(t *testing.T) { + base := Context{CI: AzurePipelines, ServerURL: "https://dev.azure.com/acme", Host: "dev.azure.com", APIURL: "https://dev.azure.com/acme", + RepoID: azID, RepoPath: "Platform/engineering-assets", RepositoryURL: "https://dev.azure.com/acme/Platform/_git/engineering-assets"} + with := func(f func(*Context)) Context { + c := base + f(&c) + return c + } + main := map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "IndividualCI"} + for _, tc := range []struct { + name string + extra map[string]string + want Context + }{ + {"a push", main, with(func(c *Context) { c.Event, c.RefName, c.RefIsBranch = "push", "main", true })}, + {"a batched push", map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "BatchedCI"}, + with(func(c *Context) { c.Event, c.RefName, c.RefIsBranch = "push", "main", true })}, + {"a schedule", map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "Schedule"}, + with(func(c *Context) { c.Event, c.RefName, c.RefIsBranch = "schedule", "main", true })}, + {"a manual run of a branch with a slash", map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/team/x", "BUILD_REASON": "Manual"}, + with(func(c *Context) { c.Event, c.RefName, c.RefIsBranch = "manual", "team/x", true })}, + {"the deployment job", map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "IndividualCI", "ENVIRONMENT_NAME": "touchmark-distribute"}, + with(func(c *Context) { + c.Event, c.RefName, c.RefIsBranch, c.Environment = "push", "main", true, "touchmark-distribute" + })}, + {"a pull request", map[string]string{"BUILD_SOURCEBRANCH": "refs/pull/41/merge", "BUILD_REASON": "PullRequest", + "SYSTEM_PULLREQUEST_SOURCEBRANCH": "refs/heads/feature", "SYSTEM_PULLREQUEST_PULLREQUESTID": "41"}, + with(func(c *Context) { c.Event, c.RefName = "pull_request", "feature" })}, + // A merge ref is a pull request's build whatever the reason says. + {"a merge ref run by hand", map[string]string{"BUILD_SOURCEBRANCH": "refs/pull/41/merge", "BUILD_REASON": "Manual"}, + with(func(c *Context) { c.Event = "pull_request" })}, + {"a pull request from main", map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "PullRequest", + "SYSTEM_PULLREQUEST_SOURCEBRANCH": "refs/heads/main"}, + with(func(c *Context) { c.Event, c.RefName = "pull_request", "main" })}, + {"a tag", map[string]string{"BUILD_SOURCEBRANCH": "refs/tags/v1", "BUILD_REASON": "IndividualCI"}, + with(func(c *Context) { c.Event, c.RefName = "push", "v1" })}, + {"another pipeline's completion", map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "ResourceTrigger"}, + with(func(c *Context) { c.Event, c.RefName, c.RefIsBranch = "resourcetrigger", "main", true })}, + {"the older organization URL", map[string]string{"SYSTEM_COLLECTIONURI": "https://acme.visualstudio.com/", "BUILD_SOURCEBRANCH": "refs/heads/main", + "BUILD_REASON": "Manual"}, + with(func(c *Context) { c.Event, c.RefName, c.RefIsBranch = "manual", "main", true })}, + {"the collection URL in its other variable", map[string]string{"SYSTEM_COLLECTIONURI": "", + "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI": "https://dev.azure.com/acme/", "BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "Manual"}, + with(func(c *Context) { c.Event, c.RefName, c.RefIsBranch = "manual", "main", true })}, + {"Azure DevOps Server", map[string]string{"SYSTEM_COLLECTIONURI": "https://tfs.acme.example/DefaultCollection/", "BUILD_SOURCEBRANCH": "refs/heads/main", + "BUILD_REASON": "Manual"}, + with(func(c *Context) { + c.ServerURL, c.APIURL, c.Host, c.Event, c.RefName, c.RefIsBranch = "", "", "", "manual", "main", true + })}, + {"a garbled repository id", map[string]string{"BUILD_REPOSITORY_ID": "0b7e5a2c", "BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "Manual"}, + with(func(c *Context) { c.RepoID, c.Event, c.RefName, c.RefIsBranch = "", "manual", "main", true })}, + // A pipeline of Azure building a GitHub repository has no hub here. + {"a GitHub repository", map[string]string{"BUILD_REPOSITORY_PROVIDER": "GitHub", "BUILD_SOURCEBRANCH": "refs/heads/main"}, + Context{CI: AzurePipelines}}, + } { + got := Detect(azureEnv(tc.extra), nil) + if got != tc.want { + t.Errorf("%s:\n got %+v\n want %+v", tc.name, got, tc.want) + } + } + c := Detect(azureEnv(main), nil) + if fp := c.Fingerprint(); fp != "dev.azure.com/"+azID { + t.Errorf("fingerprint %q", fp) + } + if c := Detect(envOf(map[string]string{"GITLAB_CI": "true", "TF_BUILD": "True"}), nil); c.CI != GitLabCI { + t.Errorf("GitLab with a stray variable: %s", c.CI) + } + if tok := Token(c, azureEnv(map[string]string{AzureTokenVar: " " + azTok + " ", "GITHUB_TOKEN": "x"})); tok != azTok { + t.Errorf("token %q", tok) + } +} + +// azThread is a comment thread of pull request 41 on azServer. +type azThread struct { + ID int64 `json:"id"` + Status string `json:"status,omitempty"` + Comments []azComment `json:"comments"` +} + +type azComment struct { + ID int64 `json:"id"` + Content string `json:"content,omitempty"` + CommentType string `json:"commentType"` + IsDeleted bool `json:"isDeleted,omitempty"` + Author struct { + ID string `json:"id"` + } `json:"author"` +} + +// azServer is the REST API of Azure DevOps for one organization, acme, +// with the hub repository, its project, its default branch main, and pull +// request 41 with its threads. The job access token acts as azSelf. +type azServer struct { + *httptest.Server + mu sync.Mutex + // noVisibility leaves the project's visibility out of the repository's + // answer, as the reference's sample does. + noVisibility bool + signIn bool + threads []azThread + posted []map[string]any + patched int + requests []string +} + +func newAzServer(t *testing.T) *azServer { + t.Helper() + s := &azServer{} + s.Server = httptest.NewServer(http.HandlerFunc(s.serve)) + t.Cleanup(s.Close) + return s +} + +func (s *azServer) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.requests = append(s.requests, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery) + if s.signIn { + w.Header().Set("Location", "https://spsprodweu5.vssps.visualstudio.com/_signin") + w.WriteHeader(http.StatusFound) + return + } + if r.Header.Get("Authorization") != "Bearer "+azTok || r.Header.Get("X-Tfs-Fedauthredirect") != "Suppress" { + w.WriteHeader(http.StatusUnauthorized) + return + } + version := r.URL.Query().Get("api-version") + repo := "/acme/_apis/git/repositories/" + azID + reply := func(v any) { _ = json.NewEncoder(w).Encode(v) } + switch { + case r.Method == http.MethodGet && r.URL.Path == repo && version == "7.1": + project := map[string]any{"id": azProject, "name": "Platform"} + if !s.noVisibility { + project["visibility"] = "private" + } + reply(map[string]any{"id": azID, "name": "engineering-assets", "defaultBranch": "refs/heads/main", "project": project}) + case r.Method == http.MethodGet && r.URL.Path == "/acme/_apis/projects/"+azProject && version == "7.1": + reply(map[string]any{"id": azProject, "name": "Platform", "visibility": "public"}) + case r.Method == http.MethodGet && r.URL.Path == repo+"/refs" && version == "7.1": + if r.URL.Query().Get("filter") != "heads/main" { + reply(map[string]any{"value": []any{}}) + return + } + reply(map[string]any{"value": []any{ + map[string]any{"name": "refs/heads/main-old", "objectId": strings.Repeat("1", 40)}, + map[string]any{"name": "refs/heads/main", "objectId": strings.Repeat("A", 40)}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/acme/_apis/connectionData" && version == "7.1-preview.1": + reply(map[string]any{"authenticatedUser": map[string]any{"id": azSelf}}) + case r.Method == http.MethodGet && r.URL.Path == repo+"/pullRequests/41/threads" && version == "7.1": + reply(map[string]any{"value": s.threads}) + case r.Method == http.MethodPost && r.URL.Path == repo+"/pullRequests/41/threads" && version == "7.1": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + s.posted = append(s.posted, body) + reply(map[string]any{"id": 99}) + case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, repo+"/pullRequests/41/threads/") && version == "7.1": + var body struct { + Content string `json:"content"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + for i := range s.threads { + for j := range s.threads[i].Comments { + c := &s.threads[i].Comments[j] + if r.URL.Path == repo+"/pullRequests/41/threads/"+itoa(s.threads[i].ID)+"/comments/"+itoa(c.ID) { + if !strings.EqualFold(c.Author.ID, azSelf) { + w.WriteHeader(http.StatusForbidden) + return + } + c.Content = body.Content + s.patched++ + reply(c) + return + } + } + } + w.WriteHeader(http.StatusNotFound) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func itoa(n int64) string { return strconv.FormatInt(n, 10) } + +// azChannel is the channel of a step building the hub on s. +func azChannel(t *testing.T, s *azServer, token string) *azureChannel { + t.Helper() + c := Detect(azureEnv(map[string]string{"BUILD_SOURCEBRANCH": "refs/heads/main", "BUILD_REASON": "IndividualCI"}), nil) + c.APIURL = s.URL + "/acme" + ch, err := New(c, httpx.New(httpx.Options{}), token) + if err != nil { + t.Fatal(err) + } + return ch.(*azureChannel) +} + +func TestAzureChannelRepository(t *testing.T) { + s := newAzServer(t) + ch := azChannel(t, s, azTok) + info, err := ch.Repository(t.Context()) + if err != nil || info != (RepoInfo{DefaultBranch: "main", Visibility: "private"}) { + t.Fatalf("Repository: %+v, %v", info, err) + } + head, err := ch.Head(t.Context()) + if err != nil || head != strings.Repeat("a", 40) { + t.Fatalf("Head: %q, %v", head, err) + } + if v, err := ch.Visibility(t.Context()); err != nil || v != "private" { + t.Errorf("Visibility: %q, %v", v, err) + } + + // Without the project's visibility in the answer, the project tells it. + s2 := newAzServer(t) + s2.noVisibility = true + info, err = azChannel(t, s2, azTok).Repository(t.Context()) + if err != nil || info.Visibility != "public" { + t.Errorf("Repository without visibility: %+v, %v", info, err) + } + + // A refused token goes to the sign-in page: say so, and how to map it. + s3 := newAzServer(t) + s3.signIn = true + _, err = azChannel(t, s3, azTok).Repository(t.Context()) + if err == nil || !strings.Contains(err.Error(), "sign-in page") || !strings.Contains(err.Error(), AzureTokenVar) { + t.Errorf("sign-in: %v", err) + } + + // Without a token the server refuses; nothing leaks. + if _, err := azChannel(t, s, "").Repository(t.Context()); err == nil { + t.Error("anonymous read succeeded") + } +} + +func TestAzureChannelComment(t *testing.T) { + s := newAzServer(t) + ch := azChannel(t, s, azTok) + const marker = `[touchmark-plan]: # "touchmark plan: acme-eng"` + body := "plan\n\n" + marker + "\n" + + // No comment yet: a closed thread with it. + if got, err := ch.UpsertComment(t.Context(), 41, marker, body); err != nil || got != CommentCreated { + t.Fatalf("first: %s, %v", got, err) + } + if len(s.posted) != 1 || s.posted[0]["status"] != "closed" { + t.Fatalf("posted %v", s.posted) + } + comments, _ := s.posted[0]["comments"].([]any) + if len(comments) != 1 || comments[0].(map[string]any)["content"] != body { + t.Fatalf("posted comments %v", comments) + } + + // Someone else's comment with the marker is left alone; touchmark's own + // is edited, and left as it is when it says the same. + other := azComment{ID: 1, Content: "copied " + marker, CommentType: "text"} + other.Author.ID = "11111111-2222-3333-4444-555555555555" + mine := azComment{ID: 2, Content: "old\n\n" + marker + "\n", CommentType: "text"} + mine.Author.ID = strings.ToUpper(azSelf) + deleted := azComment{ID: 3, CommentType: "text", IsDeleted: true} + deleted.Author.ID = azSelf + system := azComment{ID: 4, Content: marker, CommentType: "system"} + system.Author.ID = azSelf + s.threads = []azThread{{ID: 7, Comments: []azComment{other}}, {ID: 8, Comments: []azComment{deleted, system, mine}}} + s.posted = nil + if got, err := ch.UpsertComment(t.Context(), 41, marker, body); err != nil || got != CommentUpdated { + t.Fatalf("update: %s, %v", got, err) + } + if s.patched != 1 || s.threads[1].Comments[2].Content != body || s.threads[0].Comments[0].Content != "copied "+marker || len(s.posted) != 0 { + t.Fatalf("after update: patched %d, threads %+v, posted %v", s.patched, s.threads, s.posted) + } + if got, err := ch.UpsertComment(t.Context(), 41, marker, body); err != nil || got != CommentUnchanged { + t.Fatalf("again: %s, %v", got, err) + } + + if _, err := ch.UpsertComment(t.Context(), 0, marker, body); err == nil { + t.Error("pull request 0") + } + if _, err := ch.UpsertComment(t.Context(), 41, marker, "no marker"); err == nil { + t.Error("a body without its marker") + } +} + +func TestAzureChannelNeedsTheRepository(t *testing.T) { + c := Detect(azureEnv(map[string]string{"BUILD_REPOSITORY_PROVIDER": "GitHub"}), nil) + if _, err := New(c, nil, azTok); err == nil { + t.Error("a channel without a repository") + } +} diff --git a/internal/hubch/hubch.go b/internal/hubch/hubch.go index 46dab9d..d6ac169 100644 --- a/internal/hubch/hubch.go +++ b/internal/hubch/hubch.go @@ -38,7 +38,10 @@ const ( // BitbucketPipelines is Bitbucket Pipelines, which runs on Bitbucket // Cloud only (bitbucket.org). BitbucketPipelines CI = "bitbucket-pipelines" - Local CI = "local" + // AzurePipelines is Azure Pipelines building a repository of Azure + // Repos on Azure DevOps Services (dev.azure.com). + AzurePipelines CI = "azure-pipelines" + Local CI = "local" ) // GitLabUser is the user of the job token in git's Basic header; register @@ -107,6 +110,8 @@ type Context struct { // - Bitbucket Pipelines (BITBUCKET_BUILD_NUMBER set): see // detectBitbucket. Pipelines names no default branch: the hub channel // reads it (RepositoryReader). +// - Azure Pipelines (TF_BUILD=True): see detectAzure. No variable names +// the default branch or the visibility: the hub channel reads them. // - Otherwise Local with everything empty. // // readFile reads the event payload; errors reading or parsing it leave the @@ -150,6 +155,8 @@ func Detect(getenv func(string) string, readFile func(string) ([]byte, error)) C return detectGitLab(env) case env("BITBUCKET_BUILD_NUMBER") != "": return detectBitbucket(env) + case isTrue(env("TF_BUILD")): + return detectAzure(env) } return Context{CI: Local} } @@ -404,7 +411,8 @@ type Channel interface { // on GitHub Actions, CI_JOB_TOKEN on GitLab CI, GITEA_TOKEN or GITHUB_TOKEN // on Gitea and Forgejo Actions, TOUCHMARK_PIPELINES_TOKEN on Bitbucket // Pipelines (BitbucketTokenVar: Pipelines gives a step no API token of its -// own); "" when absent or Local. +// own), SYSTEM_ACCESSTOKEN on Azure Pipelines (AzureTokenVar, mapped into +// the step); "" when absent or Local. // // The caller registers the token with its redact.Registry before use, on // GitLab with the basic user GitLabUser. @@ -425,6 +433,8 @@ func Token(c Context, getenv func(string) string) string { return env("GITHUB_TOKEN") case BitbucketPipelines: return env(BitbucketTokenVar) + case AzurePipelines: + return env(AzureTokenVar) } return "" } @@ -436,6 +446,8 @@ func Token(c Context, getenv func(string) string) string { // the host of c.APIURL; // - Bitbucket: REST over client (newBitbucket), with the hub's access // token as a Bearer token sent only to the host of c.APIURL; +// - Azure DevOps: REST over client (newAzure), with the job access token +// as a Bearer token sent only to the host of c.APIURL; // - GitLab: `git ls-remote` of c.RepositoryURL with the job token passed // as an http.extraHeader through GIT_CONFIG_COUNT/KEY/VALUE environment // variables (never in the URL or argv), because CI_JOB_TOKEN cannot @@ -467,6 +479,8 @@ func New(c Context, client *httpx.Client, token string) (Channel, error) { return newGit(c, token) case BitbucketPipelines: return newBitbucket(c, client, token) + case AzurePipelines: + return newAzure(c, client, token) case Local, "": return nil, errors.New("hub channel: not running in a known CI") } diff --git a/internal/hubch/keys.go b/internal/hubch/keys.go index a9e9da6..7927355 100644 --- a/internal/hubch/keys.go +++ b/internal/hubch/keys.go @@ -2,6 +2,7 @@ package hubch import ( "context" + "encoding/base64" "errors" "fmt" "net/http" @@ -21,15 +22,15 @@ import ( // KeyStoreInput locates the hub for ReadKeyStore. type KeyStoreInput struct { - // Platform is "github", "gitlab", "gitea", "forgejo" or "bitbucket"; - // "azure-devops" is refused for now. + // Platform is "github", "gitlab", "gitea", "forgejo", "bitbucket" or + // "azure-devops". Platform string // APIURL is the REST base of the hub's platform (…/api/v3, /api/v4, - // /api/v1, https://api.github.com). + // /api/v1, https://api.github.com, https://dev.azure.com/). APIURL string // RepoID is the hub repository's immutable id (the fingerprint's): a - // number, or on Bitbucket the repository's UUID (with or without - // braces). + // number, on Bitbucket the repository's UUID (with or without braces), + // on Azure DevOps the repository's GUID. RepoID string // Token is the maintainer's token. Token string @@ -64,26 +65,40 @@ type KeyStore struct { // variables (ci_pipeline_variables_minimum_override_role); "" when // not shown. PipelineVariables string + // VariableGroups are the variable groups of the hub's project on Azure + // DevOps, with the checks and pipeline permissions of those that may + // hold a write key; VariableGroupsUnread says why they could not be + // listed ("" when they were). + VariableGroups []VariableGroup + VariableGroupsUnread string + // PipelineSettings are the Azure DevOps project's pipeline settings + // that bear on the write key; nil when they could not be read + // (PipelineSettingsUnread says why). + PipelineSettings *PipelineSettings + PipelineSettingsUnread string // Unread lists what could not be read, and why: the checks that need it // are unknown. Unread []string } -// Secret is one secret (GitHub, Gitea, Forgejo), CI/CD variable (GitLab) -// or Pipelines variable (Bitbucket). +// Secret is one secret (GitHub, Gitea, Forgejo), CI/CD variable (GitLab), +// Pipelines variable (Bitbucket) or pipeline or variable group variable +// (Azure DevOps). type Secret struct { Name string // Where is "repository", "organization" (shared with the hub), // "dependabot", "environment" (Environment names it; a deployment // variable on Bitbucket), "project" or "group" (Group names it, GitLab), - // "workspace" (Bitbucket). + // "workspace" (Bitbucket), "pipeline" (Group names the pipeline) or + // "variable group" (Group names it; Azure DevOps). Where string Environment string Group string // Protected, Masked and Scope are GitLab's: a protected variable // reaches pipelines of protected refs only; Scope is its environment // scope ("*" for every job). On Bitbucket Masked is a secured - // variable: hidden in the logs and the API. + // variable, on Azure DevOps a secret one: hidden in the logs and the + // API. Protected bool Masked bool Scope string @@ -124,7 +139,10 @@ const ( // - Gitea and Forgejo: the repository by id, the names of its Actions // secrets and of its organization's; // - Bitbucket: the repository by UUID; its Pipelines variables, its -// workspace's, and each deployment environment's (secured or not). +// workspace's, and each deployment environment's (secured or not); +// - Azure DevOps: the repository by id; the variables of the pipelines +// that build it, the project's variable groups, and the checks and +// pipeline permissions of the groups that may hold a write key. // // A listing the token may not read is noted in Unread, or in the field of // its own (EnvironmentsUnread, ProtectedBranchesUnread, @@ -139,6 +157,10 @@ func ReadKeyStore(ctx context.Context, in KeyStoreInput) (KeyStore, error) { if in.RepoID = BitbucketRepoID(in.RepoID); in.RepoID == "" { return KeyStore{}, errors.New("hub keys: the hub's repository UUID is unknown") } + case in.Platform == "azure-devops": + if in.RepoID = AzureRepoID(in.RepoID); in.RepoID == "" { + return KeyStore{}, errors.New("hub keys: the hub's repository id is unknown") + } case repoID(in.RepoID) == "": return KeyStore{}, errors.New("hub keys: the hub's repository id is unknown") } @@ -167,7 +189,10 @@ func ReadKeyStore(ctx context.Context, in KeyStoreInput) (KeyStore, error) { k.auth = &httpx.Auth{Hosts: []string{api.Host}, Header: k.header("Bearer ")} return k.bitbucket(ctx) case "azure-devops": - return KeyStore{}, errors.New("hub keys: a hub on Azure DevOps is not supported yet") + // A personal access token: Basic with an empty user name. + basic := "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+in.Token)) + k.auth = &httpx.Auth{Hosts: []string{api.Host}, Header: func(context.Context) (string, error) { return basic, nil }} + return k.azure(ctx) } return KeyStore{}, fmt.Errorf("hub keys: unknown platform %q", in.Platform) } diff --git a/internal/hubch/keys_azure.go b/internal/hubch/keys_azure.go new file mode 100644 index 0000000..21bcdc2 --- /dev/null +++ b/internal/hubch/keys_azure.go @@ -0,0 +1,257 @@ +package hubch + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// Azure DevOps keeps a hub's secrets in two places a pipeline reads: +// - the pipeline's own variables (its definition): every run of the +// pipeline gets them, of any branch, pull request builds included; a +// secret one reaches a step whose YAML maps it, and any branch's YAML +// may; +// - variable groups of the project's Library: a stage gets a group it +// links, when the pipeline may use the group and the group's checks +// pass. A Branch control check that admits the default branch only is +// what keeps every other branch's stage out. +// +// Sources (REST API 7.1; the checks and pipeline permissions are 7.1 +// previews): +// - https://learn.microsoft.com/en-us/rest/api/azure/devops/build/definitions/list +// - https://learn.microsoft.com/en-us/rest/api/azure/devops/distributedtask/variablegroups/get-variable-groups +// - https://learn.microsoft.com/en-us/rest/api/azure/devops/approvalsandchecks/check-configurations/list +// - https://learn.microsoft.com/en-us/rest/api/azure/devops/approvalsandchecks/pipeline-permissions/get +// - https://learn.microsoft.com/en-us/rest/api/azure/devops/build/general-settings/get +// +// The Branch control check's settings are not in the REST reference: their +// shape (definitionRef name evaluatebranchProtection, the string inputs +// allowedBranches, ensureProtectionOfBranch, allowUnknownStatusBranch) is +// what Microsoft's Terraform provider for Azure DevOps writes. + +// VariableGroup is a variable group of the hub's project on Azure DevOps, +// as doctor --hub-token reads it. +type VariableGroup struct { + ID int64 + Name string + // AllPipelines is set when every pipeline of the project may use the + // group (open access); PermissionsUnread says why that could not be + // read ("" when it was). + AllPipelines bool + PermissionsUnread string + // BranchChecks are its Branch control checks; ChecksUnread says why its + // checks could not be read ("" when they were). + BranchChecks []BranchCheck + ChecksUnread string +} + +// BranchCheck is a Branch control check of Azure Pipelines. +type BranchCheck struct { + // Allowed are the branches it admits (refs/heads/main, refs/heads/*, + // *), as the check lists them. + Allowed []string + // Protection is set when the branch must have a branch policy (Verify + // branch protection); AllowUnknown when a branch whose protection is + // not known passes. + Protection bool + AllowUnknown bool +} + +// PipelineSettings are the settings of an Azure DevOps project's pipelines +// (Build General Settings) that bear on the write key. +type PipelineSettings struct { + // SettableVarsLimited is "Limit variables that can be set at queue + // time" (enforceSettableVar): off, whoever may queue a run of the default + // branch sets any variable, BASH_ENV or DOCKER_HOST among them, in the + // steps that hold the write key. + SettableVarsLimited bool + // JobScopeLimited is "Limit job authorization scope to current project + // for non-release pipelines" (enforceJobAuthScope); ReposProtected is + // "Protect access to repositories in YAML pipelines" + // (enforceReferencedRepoScopedToken). + JobScopeLimited bool + ReposProtected bool +} + +// azureBranchControl is the name of the Branch control check's task. +const azureBranchControl = "evaluatebranchProtection" + +// azure reads the key store of a hub in Azure Repos: the repository by id +// (and its project), the variables of the pipelines that build it, the +// project's variable groups with each one's checks and pipeline +// permissions, and the project's pipeline settings. +func (k *keyReader) azure(ctx context.Context) (KeyStore, error) { + var repo struct { + ID string `json:"id"` + Name string `json:"name"` + DefaultBranch string `json:"defaultBranch"` + Project struct { + ID string `json:"id"` + Name string `json:"name"` + Visibility string `json:"visibility"` + } `json:"project"` + } + if err := k.azureGet(ctx, azureAPIVersion, k.segs("_apis", "git", "repositories", k.in.RepoID), nil, &repo); err != nil { + return KeyStore{}, fmt.Errorf("hub keys: read the hub repository %s: %w", k.in.RepoID, err) + } + project := AzureRepoID(repo.Project.ID) + if AzureRepoID(repo.ID) != k.in.RepoID || project == "" || repo.Name == "" { + return KeyStore{}, fmt.Errorf("hub keys: the answer names repository %q of project %q, not %s", repo.ID, repo.Project.ID, k.in.RepoID) + } + k.ks = KeyStore{Platform: "azure-devops", RepoPath: repo.Project.Name + "/" + repo.Name, DefaultBranch: strings.TrimPrefix(repo.DefaultBranch, "refs/heads/")} + vis := repo.Project.Visibility + if vis == "" { + var p struct { + Visibility string `json:"visibility"` + } + if err := k.azureGet(ctx, azureAPIVersion, k.segs("_apis", "projects", project), nil, &p); err != nil { + if err := k.unread("the hub's project", err); err != nil { + return KeyStore{}, err + } + } + vis = p.Visibility + } + k.ks.Visibility = visibility(vis) + + type variable struct { + IsSecret bool `json:"isSecret"` + } + var defs struct { + Value []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Variables map[string]variable `json:"variables"` + } `json:"value"` + } + q := url.Values{"repositoryId": {k.in.RepoID}, "repositoryType": {"TfsGit"}, "includeAllProperties": {"true"}, "$top": {"1000"}} + if err := k.azureGet(ctx, azureAPIVersion, k.segs(project, "_apis", "build", "definitions"), q, &defs); err != nil { + if err := k.unread("the variables of the hub's pipelines", err); err != nil { + return KeyStore{}, err + } + } + for _, d := range defs.Value { + for name, v := range d.Variables { + k.ks.Secrets = append(k.ks.Secrets, Secret{Name: name, Where: "pipeline", Group: d.Name, Masked: v.IsSecret}) + } + } + + var groups struct { + Value []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Variables map[string]variable `json:"variables"` + } `json:"value"` + } + if err := k.azureGet(ctx, azureAPIVersion, k.segs(project, "_apis", "distributedtask", "variablegroups"), nil, &groups); err != nil { + if err := k.unread("the project's variable groups", err); err != nil { + return KeyStore{}, err + } + k.ks.VariableGroupsUnread = k.ks.Unread[len(k.ks.Unread)-1] + } + for _, g := range groups.Value { + if g.ID <= 0 || g.Name == "" { + continue + } + vg := VariableGroup{ID: g.ID, Name: g.Name} + for name, v := range g.Variables { + k.ks.Secrets = append(k.ks.Secrets, Secret{Name: name, Where: "variable group", Group: g.Name, Masked: v.IsSecret}) + } + if err := k.azureGroupGuards(ctx, project, &vg); err != nil { + return KeyStore{}, err + } + k.ks.VariableGroups = append(k.ks.VariableGroups, vg) + } + + var settings struct { + EnforceSettableVar bool `json:"enforceSettableVar"` + EnforceJobAuthScope bool `json:"enforceJobAuthScope"` + EnforceReferencedRepoScopedToken bool `json:"enforceReferencedRepoScopedToken"` + } + if err := k.azureGet(ctx, azureAPIVersion, k.segs(project, "_apis", "build", "generalsettings"), nil, &settings); err != nil { + why, err := unreadable("the project's pipeline settings", err) + if err != nil { + return KeyStore{}, err + } + k.ks.PipelineSettingsUnread = why + } else { + k.ks.PipelineSettings = &PipelineSettings{SettableVarsLimited: settings.EnforceSettableVar, + JobScopeLimited: settings.EnforceJobAuthScope, ReposProtected: settings.EnforceReferencedRepoScopedToken} + } + return k.ks, nil +} + +// azureGroupGuards reads the checks of variable group vg and which +// pipelines may use it. A listing the token may not read is noted in the +// group. +func (k *keyReader) azureGroupGuards(ctx context.Context, project string, vg *VariableGroup) error { + id := strconv.FormatInt(vg.ID, 10) + var checks struct { + Value []struct { + Type struct { + Name string `json:"name"` + } `json:"type"` + Settings json.RawMessage `json:"settings"` + } `json:"value"` + } + q := url.Values{"resourceType": {"variablegroup"}, "resourceId": {id}, "$expand": {"settings"}} + if err := k.azureGet(ctx, azurePreviewVersion, k.segs(project, "_apis", "pipelines", "checks", "configurations"), q, &checks); err != nil { + why, err := unreadable("the checks of variable group "+vg.Name, err) + if err != nil { + return err + } + vg.ChecksUnread = why + } + for _, c := range checks.Value { + var s struct { + DefinitionRef struct { + Name string `json:"name"` + } `json:"definitionRef"` + Inputs map[string]string `json:"inputs"` + } + if json.Unmarshal(c.Settings, &s) != nil || !strings.EqualFold(s.DefinitionRef.Name, azureBranchControl) { + continue + } + bc := BranchCheck{Protection: strings.EqualFold(s.Inputs["ensureProtectionOfBranch"], "true")} + bc.AllowUnknown = strings.EqualFold(s.Inputs["allowUnknownStatusBranch"], "true") || strings.EqualFold(s.Inputs["allowUnknownStatusBranches"], "true") + for b := range strings.SplitSeq(s.Inputs["allowedBranches"], ",") { + if b = strings.TrimSpace(b); b != "" { + bc.Allowed = append(bc.Allowed, b) + } + } + vg.BranchChecks = append(vg.BranchChecks, bc) + } + var perms struct { + AllPipelines *struct { + Authorized bool `json:"authorized"` + } `json:"allPipelines"` + } + if err := k.azureGet(ctx, azurePreviewVersion, k.segs(project, "_apis", "pipelines", "pipelinepermissions", "variablegroup", id), nil, &perms); err != nil { + why, err := unreadable("the pipeline permissions of variable group "+vg.Name, err) + if err != nil { + return err + } + vg.PermissionsUnread = why + } + vg.AllPipelines = perms.AllPipelines != nil && perms.AllPipelines.Authorized + return nil +} + +// azureGet reads u with api-version and query into out, through the +// key reader's credential, refusing a redirect to the sign-in page. +func (k *keyReader) azureGet(ctx context.Context, version, u string, query url.Values, out any) error { + q := url.Values{} + for key, v := range query { + q[key] = v + } + q.Set("api-version", version) + resp, err := k.in.Client.JSONWith(ctx, http.MethodGet, u+"?"+q.Encode(), k.auth, azureHeaders(), nil, out) + if resp != nil && (resp.Status == http.StatusNonAuthoritativeInfo || resp.Status/100 == 3) { + return errors.New("sent to the sign-in page of Azure DevOps: the token is missing, expired or refused") + } + return err +} diff --git a/internal/hubch/keys_azure_test.go b/internal/hubch/keys_azure_test.go new file mode 100644 index 0000000..275bb55 --- /dev/null +++ b/internal/hubch/keys_azure_test.go @@ -0,0 +1,103 @@ +package hubch + +import ( + "encoding/base64" + "slices" + "strings" + "testing" +) + +// azureKeyRoutes are the answers of Azure DevOps for the hub repository's +// key store: one pipeline with a secret write key (the mistake), the group +// touchmark-distribute with the key and a Branch control check for main +// (settings as Microsoft's Terraform provider writes them), open to every +// pipeline, a group with no key, and the project's pipeline settings. +func azureKeyRoutes() map[string]string { + const org = "/acme" + const project = org + "/" + azProject + return map[string]string{ + org + "/_apis/git/repositories/" + azID: `{"id": "` + azID + `", "name": "engineering-assets", "defaultBranch": "refs/heads/main", + "project": {"id": "` + azProject + `", "name": "Platform"}}`, + org + "/_apis/projects/" + azProject: `{"id": "` + azProject + `", "visibility": "private"}`, + project + "/_apis/build/definitions": `{"count": 1, "value": [{"id": 3, "name": "engineering-assets", + "variables": {"TOUCHMARK_READ_TOKEN": {"value": null, "isSecret": true}, "TOUCHMARK_GH_WRITE_TOKEN": {"value": null, "isSecret": true}}}]}`, + project + "/_apis/distributedtask/variablegroups": `{"count": 2, "value": [ + {"id": 5, "name": "touchmark-distribute", "type": "Vsts", "variables": {"TOUCHMARK_WRITE_TOKEN": {"value": null, "isSecret": true}}}, + {"id": 6, "name": "shared", "type": "Vsts", "variables": {"COLOR": {"value": "blue"}}}]}`, + project + "/_apis/pipelines/checks/configurations": `{"count": 2, "value": [ + {"id": 1, "type": {"id": "8c6f20a7-a545-4486-9777-f762fafe0d4d", "name": "Approval"}, "settings": {"approvers": []}}, + {"id": 2, "type": {"id": "fe1de3ee-a436-41b4-bb20-f6eb4cb879a7", "name": "Task Check"}, + "settings": {"definitionRef": {"id": "86b05a0c-73e6-4f7d-b3cf-e38f3b39a75b", "name": "evaluatebranchProtection", "version": "0.0.1"}, + "displayName": "Branch control", + "inputs": {"allowedBranches": "refs/heads/main, refs/heads/release/*", "ensureProtectionOfBranch": "true", "allowUnknownStatusBranch": "false"}}}]}`, + project + "/_apis/build/generalsettings": `{"enforceSettableVar": true, "enforceJobAuthScope": false, "enforceReferencedRepoScopedToken": true}`, + project + "/_apis/pipelines/pipelinepermissions/variablegroup/5": `{"resource": {"type": "variablegroup", "id": "5"}, + "allPipelines": {"authorized": true}, "pipelines": []}`, + } +} + +func TestReadKeyStoreAzure(t *testing.T) { + basic := "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+maintainerToken)) + s := newKeyServer(t, "Authorization", basic, azureKeyRoutes()) + ks, err := ReadKeyStore(t.Context(), KeyStoreInput{Platform: "azure-devops", APIURL: s.URL + "/acme", RepoID: strings.ToUpper(azID), Token: maintainerToken}) + if err != nil { + t.Fatal(err) + } + if ks.Platform != "azure-devops" || ks.RepoPath != "Platform/engineering-assets" || ks.DefaultBranch != "main" || ks.Visibility != "private" { + t.Errorf("store %+v", ks) + } + var got []string + for _, sec := range ks.Secrets { + got = append(got, sec.Where+":"+sec.Group+":"+sec.Name) + if !sec.Masked && sec.Name != "COLOR" { + t.Errorf("%s is secret", sec.Name) + } + } + slices.Sort(got) + want := []string{"pipeline:engineering-assets:TOUCHMARK_GH_WRITE_TOKEN", "pipeline:engineering-assets:TOUCHMARK_READ_TOKEN", + "variable group:shared:COLOR", "variable group:touchmark-distribute:TOUCHMARK_WRITE_TOKEN"} + if !slices.Equal(got, want) { + t.Errorf("secrets %q, want %q", got, want) + } + if len(ks.VariableGroups) != 2 { + t.Fatalf("groups %+v", ks.VariableGroups) + } + g := ks.VariableGroups[0] + if g.Name != "touchmark-distribute" || !g.AllPipelines || len(g.BranchChecks) != 1 || g.ChecksUnread != "" { + t.Fatalf("group %+v", g) + } + if bc := g.BranchChecks[0]; !slices.Equal(bc.Allowed, []string{"refs/heads/main", "refs/heads/release/*"}) || !bc.Protection || bc.AllowUnknown { + t.Errorf("branch check %+v", bc) + } + if st := ks.PipelineSettings; st == nil || !st.SettableVarsLimited || st.JobScopeLimited || !st.ReposProtected { + t.Errorf("pipeline settings %+v (%s)", ks.PipelineSettings, ks.PipelineSettingsUnread) + } + // The second group's permissions are not in the stand-in: noted, not fatal. + if ks.VariableGroups[1].PermissionsUnread == "" { + t.Errorf("group shared: %+v", ks.VariableGroups[1]) + } + for _, a := range s.auth { + if a != basic { + t.Errorf("a request with %q", a) + } + } + + // A token that may not read the checks: noted on the group, the rest + // stands. + routes := azureKeyRoutes() + routes["/acme/"+azProject+"/_apis/pipelines/checks/configurations"] = "status 403" + s = newKeyServer(t, "Authorization", basic, routes) + ks, err = ReadKeyStore(t.Context(), KeyStoreInput{Platform: "azure-devops", APIURL: s.URL + "/acme", RepoID: azID, Token: maintainerToken}) + if err != nil || ks.VariableGroups[0].ChecksUnread == "" { + t.Fatalf("checks refused: %+v, %v", ks.VariableGroups, err) + } + + // A refused token ends the reading. + s = newKeyServer(t, "Authorization", "Basic other", azureKeyRoutes()) + if _, err := ReadKeyStore(t.Context(), KeyStoreInput{Platform: "azure-devops", APIURL: s.URL + "/acme", RepoID: azID, Token: maintainerToken}); err == nil { + t.Error("a refused token") + } + if _, err := ReadKeyStore(t.Context(), KeyStoreInput{Platform: "azure-devops", APIURL: s.URL + "/acme", RepoID: "712345678", Token: maintainerToken}); err == nil { + t.Error("a repository id that is not a GUID") + } +} diff --git a/schemas/doctor.schema.json b/schemas/doctor.schema.json index 710fa43..ed2acb0 100644 --- a/schemas/doctor.schema.json +++ b/schemas/doctor.schema.json @@ -79,7 +79,7 @@ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, "fingerprint": { - "description": "The hub's host and immutable repository id, e.g. github.com/712345678; on Bitbucket the repository UUID, lowercase without braces.", + "description": "The hub's host and immutable repository id, e.g. github.com/712345678; on Bitbucket the repository UUID, lowercase without braces; on Azure DevOps the repository id, lowercase.", "type": "string", "pattern": "^[A-Za-z0-9.-]+(:[0-9]{1,5})?/([0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$" }, diff --git a/schemas/hub.schema.json b/schemas/hub.schema.json index 782ec4c..7ec8b2f 100644 --- a/schemas/hub.schema.json +++ b/schemas/hub.schema.json @@ -29,7 +29,7 @@ "items": { "$ref": "#/$defs/branch" } }, "previous_fingerprints": { - "description": "Former fingerprints of this hub (host/repository-id: a numeric id, or a Bitbucket repository UUID with or without braces), after the hub moved.", + "description": "Former fingerprints of this hub (host/repository-id: a numeric id, a Bitbucket repository UUID with or without braces, or an Azure Repos repository id), after the hub moved.", "type": ["array", "null"], "items": { "type": "string", @@ -136,11 +136,11 @@ "additionalProperties": false, "properties": { "write_isolation": { - "description": "Where the write key lives. platform: in the hub's CI platform, checked by a probe (on Bitbucket Pipelines only with a reason); external: issued by an external vault over OIDC; none: risk accepted, needs a reason. Default: platform.", + "description": "Where the write key lives. platform: in the hub's CI platform, checked by a probe (on Bitbucket Pipelines and Azure Pipelines only with a reason); external: issued by an external vault over OIDC; none: risk accepted, needs a reason. Default: platform.", "enum": ["platform", "external", "none"] }, "reason": { - "description": "Why the write key cannot be isolated. Required with write_isolation: none, and on Bitbucket Pipelines with platform, where it states the deployment permissions (Premium) that keep the write key on the default branch, which touchmark cannot read.", + "description": "Why the write key cannot be isolated. Required with write_isolation: none, and with platform on Bitbucket Pipelines, where it states the deployment permissions (Premium) that keep the write key on the default branch, and on Azure Pipelines, where it states the Branch control check of the variable group that holds it: touchmark cannot read either.", "type": "string" }, "private_targets_in_public_hub": { diff --git a/schemas/report.schema.json b/schemas/report.schema.json index 7b11508..bdcb19a 100644 --- a/schemas/report.schema.json +++ b/schemas/report.schema.json @@ -124,7 +124,7 @@ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, "fingerprint": { - "description": "The hub's host and immutable repository id, e.g. github.com/712345678; on Bitbucket the repository UUID, lowercase without braces.", + "description": "The hub's host and immutable repository id, e.g. github.com/712345678; on Bitbucket the repository UUID, lowercase without braces; on Azure DevOps the repository id, lowercase.", "type": "string", "pattern": "^[A-Za-z0-9.-]+(:[0-9]{1,5})?/([0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$" }, diff --git a/zensical.toml b/zensical.toml index a526de3..a7fc147 100644 --- a/zensical.toml +++ b/zensical.toml @@ -21,6 +21,7 @@ nav = [ { "A hub on GitLab" = "getting-started/gitlab.md" }, { "A hub on Gitea or Forgejo" = "getting-started/gitea-forgejo.md" }, { "A hub on Bitbucket Cloud" = "getting-started/bitbucket.md" }, + { "A hub on Azure DevOps" = "getting-started/azure-devops.md" }, { "Opt a repository in" = "getting-started/opt-in.md" }, ] }, { "Concepts" = [