From fbfce8841a3c4526f33d1d7c2efb3715f0feeaaa Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 22:26:49 -0700 Subject: [PATCH 1/2] feat(oci): plan immutable release tags Add internal/rel, the puboci StateReader port and planning engine, the oras-go v2 read adapter, and the plan tags command. No workflow change: the github-script planner stays authoritative until the two-phase path lands. --- .mockery.yml | 7 + cmd/release-cli/main.go | 10 + docs/reference/oci-image-contract.md | 4 + docs/reference/release-cli-contract.md | 101 +++- go.mod | 5 + go.sum | 24 + internal/adapter/reg/client.go | 107 +++++ internal/adapter/reg/doc.go | 7 + internal/adapter/reg/mocks/doc.go | 5 + internal/adapter/reg/mocks/state_reader.go | 173 +++++++ internal/adapter/reg/state.go | 144 ++++++ internal/adapter/reg/state_test.go | 297 ++++++++++++ internal/cli/doc.go | 9 +- internal/cli/root.go | 16 + internal/cli/tags.go | 301 ++++++++++++ internal/cli/tags_test.go | 517 +++++++++++++++++++++ internal/rel/digest.go | 69 +++ internal/rel/digest_test.go | 82 ++++ internal/rel/doc.go | 6 + internal/rel/secret.go | 51 ++ internal/rel/secret_test.go | 59 +++ internal/rel/tag.go | 320 +++++++++++++ internal/rel/tag_test.go | 374 +++++++++++++++ internal/rel/version.go | 148 ++++++ internal/rel/version_test.go | 223 +++++++++ internal/stage/puboci/doc.go | 6 + internal/stage/puboci/tags.go | 209 +++++++++ internal/stage/puboci/tags_test.go | 412 ++++++++++++++++ 28 files changed, 3680 insertions(+), 6 deletions(-) create mode 100644 internal/adapter/reg/client.go create mode 100644 internal/adapter/reg/doc.go create mode 100644 internal/adapter/reg/mocks/doc.go create mode 100644 internal/adapter/reg/mocks/state_reader.go create mode 100644 internal/adapter/reg/state.go create mode 100644 internal/adapter/reg/state_test.go create mode 100644 internal/cli/tags.go create mode 100644 internal/cli/tags_test.go create mode 100644 internal/rel/digest.go create mode 100644 internal/rel/digest_test.go create mode 100644 internal/rel/doc.go create mode 100644 internal/rel/secret.go create mode 100644 internal/rel/secret_test.go create mode 100644 internal/rel/tag.go create mode 100644 internal/rel/tag_test.go create mode 100644 internal/rel/version.go create mode 100644 internal/rel/version_test.go create mode 100644 internal/stage/puboci/doc.go create mode 100644 internal/stage/puboci/tags.go create mode 100644 internal/stage/puboci/tags_test.go diff --git a/.mockery.yml b/.mockery.yml index 075ccc6..788f266 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -23,3 +23,10 @@ packages: ArtifactMeta: config: filename: artifact_meta.go + github.com/meigma/release/internal/stage/puboci: + config: + dir: internal/adapter/reg/mocks + interfaces: + StateReader: + config: + filename: state_reader.go diff --git a/cmd/release-cli/main.go b/cmd/release-cli/main.go index ef3feab..2b963b5 100644 --- a/cmd/release-cli/main.go +++ b/cmd/release-cli/main.go @@ -8,8 +8,10 @@ import ( "syscall" "github.com/meigma/release/internal/adapter/ghact" + "github.com/meigma/release/internal/adapter/reg" "github.com/meigma/release/internal/cli" "github.com/meigma/release/internal/stage/pubgh" + "github.com/meigma/release/internal/stage/puboci" ) //nolint:gochecknoglobals // Linker-injected build metadata. @@ -35,6 +37,14 @@ func run() int { NewArtifactMeta: func(token string, endpoint cli.GitHubEndpoint) (pubgh.ArtifactMeta, error) { return ghact.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL) }, + NewStateReader: func(credentials cli.RegistryCredentials) (puboci.StateReader, error) { + return reg.New(reg.Options{ + Credentials: reg.Credentials{ + Username: credentials.Username, + Password: credentials.Password, + }, + }), nil + }, Build: cli.BuildInfo{ Version: version, Commit: commit, diff --git a/docs/reference/oci-image-contract.md b/docs/reference/oci-image-contract.md index 91f04a2..b60fdc3 100644 --- a/docs/reference/oci-image-contract.md +++ b/docs/reference/oci-image-contract.md @@ -203,6 +203,10 @@ A stable release tag `vMAJOR.MINOR.PATCH` publishes: The exact tag must resolve to the builder's expected OCI index digest after publication. Each eligible channel tag must resolve to that digest; an out-of-order or backport release leaves newer channel tags unchanged. The publisher resolves and validates every existing tag before uploading the image. A repository-wide publisher concurrency group prevents different release tags from planning and updating channels concurrently. Prerelease, build-metadata, malformed, branch, and untagged refs are rejected. +`release-cli plan tags` evaluates the same exact-tag and channel policy as the publisher's planning step. It can run independently to inspect the decisions for a candidate release. The publisher's existing `actions/github-script` planning step remains authoritative for publication in this release. The workflow does not call `plan tags`. + +A direct `plan tags` invocation has no repository-wide concurrency lock. Two concurrent planners outside the publisher workflow can observe the same registry state and plan conflicting channel moves. Direct use therefore requires a single writer by convention. + Digest-pinned references are the durable consumer interface: ```text diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md index ab8c31f..a13be57 100644 --- a/docs/reference/release-cli-contract.md +++ b/docs/reference/release-cli-contract.md @@ -7,6 +7,7 @@ | Command | Purpose | | --- | --- | | `release-cli stage --profile go --dist PATH [--json]` | Validate the staged Go release files under `PATH`. | +| `release-cli plan tags [--image IMAGE] [--version VERSION] --digest DIGEST [--json]` | Inspect the immutable exact tag and moving channel tags for an OCI release. | | `release-cli verify handoff --artifact-id --digest [--json]` | Verify an Actions artifact's GitHub API metadata before download. | | `release-cli version [--json]` | Report the CLI version, source commit, and protocol integer. | @@ -25,7 +26,7 @@ When option and argument parsing succeeds and `--json` is requested, stdout cont | Field | Value | | --- | --- | | `schema` | Always `release.dev/result/v1`. | -| `command` | The command path, such as `stage`, `verify handoff`, or `version`. | +| `command` | The command path, such as `plan tags`, `stage`, `verify handoff`, or `version`. | | `ok` | `true` when the command succeeds; otherwise `false`. | | `result` | The command-specific result object. | @@ -38,6 +39,36 @@ The `stage --json` result contains these fields: | `binaries..path` | string | Original `/`-prefixed path from `artifacts.json`. | | `binaries..mode` | string | Observed permission bits in octal notation. | +For `plan tags --json`, `command` is exactly `plan tags`. The `result` object contains these fields: + +| Field | JSON type | Value | +| --- | --- | --- | +| `image` | string | OCI image name whose tags were inspected. | +| `version` | string | Candidate stable release version. | +| `digest` | string | Candidate OCI index digest, normalized to lowercase with the `sha256:` prefix. | +| `tags` | array of strings | Tags with a `create` decision, in decision order. Tags with an `accept` or `retain` decision are omitted. | +| `decisions` | array of objects | Decision for the exact tag and each channel tag, in policy order. | +| `decisions[].tag` | string | Exact or channel tag that was evaluated. | +| `decisions[].scope` | string | Tag scope: `exact`, `minor`, `major`, or `latest`. | +| `decisions[].action` | string | Result: `create`, `accept`, or `retain`. | + +For example, this result plans to apply the exact and minor tags, retain the major tag, and accept the existing `latest` tag: + +```json +{ + "image": "ghcr.io/owner/repo", + "version": "1.2.3", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tags": ["1.2.3", "1.2"], + "decisions": [ + {"tag": "1.2.3", "scope": "exact", "action": "create"}, + {"tag": "1.2", "scope": "minor", "action": "create"}, + {"tag": "1", "scope": "major", "action": "retain"}, + {"tag": "latest", "scope": "latest", "action": "accept"} + ] +} +``` + The `version --json` result contains exactly these fields: | Field | JSON type | Value | @@ -77,7 +108,7 @@ flag, an invalid flag value, or the wrong number of arguments, no envelope is written; the usage error goes to stderr and the process exits with code `2`. -Without `--json`, a successful `stage` or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli (, protocol )` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for all commands. +Without `--json`, a successful `plan tags`, `stage`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli (, protocol )` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for all commands. ## Exit codes @@ -89,6 +120,72 @@ Without `--json`, a successful `stage` or `verify handoff` command writes nothin No other exit code is defined; in particular, code `3` has no meaning. An exit code does not make a general promise that a command is safe to run again. +## OCI tag planning + +`release-cli plan tags` inspects the current registry state and returns the tag decisions for one candidate OCI index. + +| Value | Flag | Environment variable | Default | +| --- | --- | --- | --- | +| Image | `--image` | `RELEASE_IMAGE` | `ghcr.io//`, lowercased from `GITHUB_REPOSITORY`. | +| Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` with one optional leading `v` stripped. | +| Digest | `--digest` | `RELEASE_DIGEST` | None. A digest is required. | +| JSON output | `--json` | None | Disabled. | + +An explicitly set flag takes precedence over its environment variable. The derived default applies only when the corresponding flag and release environment variable are absent. The image must have the lowercase form `host/path[/path...]` without a tag or digest. The digest must have the `sha256:` prefix followed by 64 hexadecimal digits. + +The command resolves registry credentials in this order: + +| Credential | Resolution | +| --- | --- | +| Token | Nonempty `GITHUB_TOKEN`, then nonempty `GH_TOKEN`. | +| Username | Nonempty `GITHUB_ACTOR`, then `x-access-token`. | + +If neither token is present, the command reads the registry anonymously. Anonymous reads work only for public packages. + +Missing or invalid configuration exits with code `2`. A planning or registry failure exits with code `1`. + +`plan tags` performs registry reads only. It never writes a tag, blob, or manifest. The reusable publisher workflow still owns tag application in this release. Its existing `actions/github-script` tag planner remains authoritative for publication. The workflow does not call `plan tags` in this release. The command supports planning and inspection only. + +### Tag policy + +The candidate version must match this canonical stable-version grammar: + +```text +^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ +``` + +The version has exactly three components. It has no `v` prefix, leading zeros, prerelease, or build metadata. Each component must fit in a 64-bit unsigned integer. The publisher workflow compares components with arbitrary-precision integers. The CLI's 64-bit limit is a deliberate fail-closed narrowing of that workflow behavior. + +The exact tag is `MAJOR.MINOR.PATCH`: + +| Current exact-tag state | Decision | +| --- | --- | +| The tag is absent. | `create`: apply the tag to the candidate digest. | +| The tag resolves to the candidate digest. | `accept`: leave the tag unchanged. | +| The tag resolves to another digest. | Fail with an immutable-tag conflict. | + +The command then evaluates channels in this order: + +| Channel tag | Scope | Required release line | +| --- | --- | --- | +| `MAJOR.MINOR` | `minor` | The current annotation must have the candidate's major and minor components. | +| `MAJOR` | `major` | The current annotation must have the candidate's major component. | +| `latest` | `latest` | No release-line check. | + +An absent channel gets a `create` decision. A channel that already resolves to the candidate digest gets an `accept` decision. Otherwise, the command reads the current manifest's `org.opencontainers.image.version` annotation. A missing or invalid stable-version annotation fails planning. A minor or major channel outside its required release line also fails planning. + +For a valid channel annotation on a different digest, the command compares the candidate version with the annotated version: + +| Comparison | Decision | +| --- | --- | +| The candidate is newer. | `create`: move the channel to the candidate digest. | +| The candidate is older. | `retain`: keep the channel on the newer release. | +| The versions are equal. | Fail because equal versions on different digests are corrupt state. | + +### Concurrency + +The publisher workflow serializes tag planning and application with a repository-wide concurrency group. A direct `plan tags` invocation outside that workflow has no cross-run lock. Two concurrent planners can observe the same registry state and plan conflicting channel moves. Direct use therefore requires a single writer by convention. + ## Actions artifact handoff `verify handoff` reads the artifact metadata from the GitHub Actions API before any artifact download. It validates all of these conditions: diff --git a/go.mod b/go.mod index ee4480c..96ba1cd 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,12 @@ module github.com/meigma/release go 1.26.6 require ( + github.com/google/go-containerregistry v0.21.9 github.com/google/go-github/v82 v82.0.0 + github.com/opencontainers/image-spec v1.1.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + oras.land/oras-go/v2 v2.6.2 ) require ( @@ -13,9 +16,11 @@ require ( github.com/google/go-querystring v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect + golang.org/x/sync v0.22.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 39c2284..83ff223 100644 --- a/go.sum +++ b/go.sum @@ -2,25 +2,39 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs= +github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -31,8 +45,18 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= +oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= diff --git a/internal/adapter/reg/client.go b/internal/adapter/reg/client.go new file mode 100644 index 0000000..5e443dd --- /dev/null +++ b/internal/adapter/reg/client.go @@ -0,0 +1,107 @@ +package reg + +import ( + "fmt" + "net/http" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +// Credentials is a registry username and password. +// +// A zero value is an anonymous read. Password is a [rel.Secret] so token +// text is not printed, logged, or encoded. +type Credentials struct { + // Username is the registry user. An empty username with a password is + // valid for token auth. + Username string + + // Password is the registry password or token. Reveal it only when + // composing the oras credential. + Password rel.Secret +} + +// Options configures a read-only registry [Client]. +type Options struct { + // Credentials authenticates registry reads. The zero value is anonymous. + Credentials Credentials + + // PlainHTTP forces HTTP instead of HTTPS. Tests use this against a + // local registry. + PlainHTTP bool + + // HTTPClient is the optional transport. Nil selects a default client. + HTTPClient *http.Client +} + +// Client reads tag state from a GHCR-compatible registry. +// +// It implements [puboci.StateReader]. It never pushes, tags, or deletes. +type Client struct { + // auth is the shared oras auth client. Credential is applied per + // request so the registry host is known and token text is not stored + // on this value. + auth *auth.Client + + // options is the constructor configuration, including the redacted + // secret used to build per-request credentials. + options Options +} + +// New constructs a [Client] from options. +// +// Token text stays inside [rel.Secret] until a request is built. The +// returned client is safe to format: password text is never a plain field. +func New(options Options) *Client { + return &Client{ + auth: &auth.Client{ + Client: options.HTTPClient, + Cache: auth.NewCache(), + }, + options: options, + } +} + +// String reports the client without credential material. +func (c *Client) String() string { + if c == nil { + return "" + } + + return fmt.Sprintf("reg.Client{authenticated:%t plainHTTP:%t}", c.hasCredentials(), c.options.PlainHTTP) +} + +// GoString reports the client without credential material. +func (c *Client) GoString() string { + return c.String() +} + +// repository builds a remote repository client for ref. +func (c *Client) repository(ref puboci.Reference) (*remote.Repository, error) { + repo, err := remote.NewRepository(ref.Image.String()) + if err != nil { + return nil, fmt.Errorf("parse repository: %w", err) + } + + authClient := *c.auth + if c.hasCredentials() { + authClient.Credential = auth.StaticCredential(repo.Reference.Host(), auth.Credential{ + Username: c.options.Credentials.Username, + Password: c.options.Credentials.Password.Reveal(), + }) + } + + repo.Client = &authClient + repo.PlainHTTP = c.options.PlainHTTP + + return repo, nil +} + +// hasCredentials reports whether options include a username or password. +func (c *Client) hasCredentials() bool { + return c.options.Credentials.Username != "" || !c.options.Credentials.Password.IsEmpty() +} diff --git a/internal/adapter/reg/doc.go b/internal/adapter/reg/doc.go new file mode 100644 index 0000000..09bdc3f --- /dev/null +++ b/internal/adapter/reg/doc.go @@ -0,0 +1,7 @@ +// Package reg implements [puboci.StateReader] with oras-go. +// +// [New] builds a read-only registry client. Token text is applied only when +// building a per-request authenticated transport and is never stored in a +// formattable field or included in returned errors. Resolve and Version +// classify registry failures as absent, auth, retryable, or corrupt. +package reg diff --git a/internal/adapter/reg/mocks/doc.go b/internal/adapter/reg/mocks/doc.go new file mode 100644 index 0000000..92723ff --- /dev/null +++ b/internal/adapter/reg/mocks/doc.go @@ -0,0 +1,5 @@ +// Package mocks contains Mockery-generated doubles for [puboci.StateReader]. +// +// Generated files are produced by `mockery` from .mockery.yml. Do not edit +// them by hand. +package mocks diff --git a/internal/adapter/reg/mocks/state_reader.go b/internal/adapter/reg/mocks/state_reader.go new file mode 100644 index 0000000..dfa6a1b --- /dev/null +++ b/internal/adapter/reg/mocks/state_reader.go @@ -0,0 +1,173 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +// NewMockStateReader creates a new instance of MockStateReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockStateReader(t interface { + mock.TestingT + Cleanup(func()) +}) *MockStateReader { + mock := &MockStateReader{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockStateReader is an autogenerated mock type for the StateReader type +type MockStateReader struct { + mock.Mock +} + +type MockStateReader_Expecter struct { + mock *mock.Mock +} + +func (_m *MockStateReader) EXPECT() *MockStateReader_Expecter { + return &MockStateReader_Expecter{mock: &_m.Mock} +} + +// Resolve provides a mock function for the type MockStateReader +func (_mock *MockStateReader) Resolve(ctx context.Context, ref puboci.Reference) (rel.Digest, error) { + ret := _mock.Called(ctx, ref) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 rel.Digest + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, puboci.Reference) (rel.Digest, error)); ok { + return returnFunc(ctx, ref) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, puboci.Reference) rel.Digest); ok { + r0 = returnFunc(ctx, ref) + } else { + r0 = ret.Get(0).(rel.Digest) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, puboci.Reference) error); ok { + r1 = returnFunc(ctx, ref) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockStateReader_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type MockStateReader_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - ctx context.Context +// - ref puboci.Reference +func (_e *MockStateReader_Expecter) Resolve(ctx any, ref any) *MockStateReader_Resolve_Call { + return &MockStateReader_Resolve_Call{Call: _e.mock.On("Resolve", ctx, ref)} +} + +func (_c *MockStateReader_Resolve_Call) Run(run func(ctx context.Context, ref puboci.Reference)) *MockStateReader_Resolve_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 puboci.Reference + if args[1] != nil { + arg1 = args[1].(puboci.Reference) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStateReader_Resolve_Call) Return(digest rel.Digest, err error) *MockStateReader_Resolve_Call { + _c.Call.Return(digest, err) + return _c +} + +func (_c *MockStateReader_Resolve_Call) RunAndReturn(run func(ctx context.Context, ref puboci.Reference) (rel.Digest, error)) *MockStateReader_Resolve_Call { + _c.Call.Return(run) + return _c +} + +// Version provides a mock function for the type MockStateReader +func (_mock *MockStateReader) Version(ctx context.Context, ref puboci.Reference) (rel.Version, error) { + ret := _mock.Called(ctx, ref) + + if len(ret) == 0 { + panic("no return value specified for Version") + } + + var r0 rel.Version + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, puboci.Reference) (rel.Version, error)); ok { + return returnFunc(ctx, ref) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, puboci.Reference) rel.Version); ok { + r0 = returnFunc(ctx, ref) + } else { + r0 = ret.Get(0).(rel.Version) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, puboci.Reference) error); ok { + r1 = returnFunc(ctx, ref) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockStateReader_Version_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Version' +type MockStateReader_Version_Call struct { + *mock.Call +} + +// Version is a helper method to define mock.On call +// - ctx context.Context +// - ref puboci.Reference +func (_e *MockStateReader_Expecter) Version(ctx any, ref any) *MockStateReader_Version_Call { + return &MockStateReader_Version_Call{Call: _e.mock.On("Version", ctx, ref)} +} + +func (_c *MockStateReader_Version_Call) Run(run func(ctx context.Context, ref puboci.Reference)) *MockStateReader_Version_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 puboci.Reference + if args[1] != nil { + arg1 = args[1].(puboci.Reference) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStateReader_Version_Call) Return(version rel.Version, err error) *MockStateReader_Version_Call { + _c.Call.Return(version, err) + return _c +} + +func (_c *MockStateReader_Version_Call) RunAndReturn(run func(ctx context.Context, ref puboci.Reference) (rel.Version, error)) *MockStateReader_Version_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/adapter/reg/state.go b/internal/adapter/reg/state.go new file mode 100644 index 0000000..b1be57c --- /dev/null +++ b/internal/adapter/reg/state.go @@ -0,0 +1,144 @@ +package reg + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/registry/remote/errcode" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + // bytesPerKiB is the number of bytes in a kibibyte. + bytesPerKiB = 1024 + // kibibytesPerMiB is the number of kibibytes in a mebibyte. + kibibytesPerMiB = 1024 + // manifestLimitBytes is the maximum encoded manifest Version will read. + manifestLimitBytes int64 = 4 * bytesPerKiB * kibibytesPerMiB +) + +// annotationFile is the subset of an OCI manifest Version needs. +type annotationFile struct { + // Annotations holds OCI manifest annotations. + Annotations map[string]string `json:"annotations"` +} + +// Resolve implements [puboci.StateReader]. +func (c *Client) Resolve(ctx context.Context, ref puboci.Reference) (rel.Digest, error) { + if ctx == nil { + return "", errors.New("context is nil") + } + if c == nil || c.auth == nil { + return "", errors.New("registry client is nil") + } + + return c.resolve(ctx, ref) +} + +// Version implements [puboci.StateReader]. +func (c *Client) Version(ctx context.Context, ref puboci.Reference) (rel.Version, error) { + if ctx == nil { + return rel.Version{}, errors.New("context is nil") + } + if c == nil || c.auth == nil { + return rel.Version{}, errors.New("registry client is nil") + } + + return c.version(ctx, ref) +} + +// resolve looks up the digest for ref after exported guards. +func (c *Client) resolve(ctx context.Context, ref puboci.Reference) (rel.Digest, error) { + repo, err := c.repository(ref) + if err != nil { + return "", err + } + + desc, err := repo.Resolve(ctx, ref.Tag.String()) + if err != nil { + return "", classify(err) + } + + digest, err := rel.ParseDigest(desc.Digest.String()) + if err != nil { + return "", fmt.Errorf("registry digest: %w", err) + } + + return digest, nil +} + +// version reads the version annotation for ref after exported guards. +func (c *Client) version(ctx context.Context, ref puboci.Reference) (rel.Version, error) { + repo, err := c.repository(ref) + if err != nil { + return rel.Version{}, err + } + + _, body, err := repo.FetchReference(ctx, ref.Tag.String()) + if err != nil { + return rel.Version{}, classify(err) + } + defer body.Close() + + return decodeVersion(body) +} + +// classify maps an oras failure onto a puboci sentinel or a diagnostic. +// +// The returned error never includes credentials, Authorization headers, or +// request URLs. +func classify(err error) error { + if errors.Is(err, context.Canceled) { + return fmt.Errorf("%w: request canceled", context.Canceled) + } + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%w: request deadline exceeded", context.DeadlineExceeded) + } + if errors.Is(err, errdef.ErrNotFound) { + return fmt.Errorf("%w", puboci.ErrTagAbsent) + } + + var respErr *errcode.ErrorResponse + if !errors.As(err, &respErr) { + return errors.New("registry request failed") + } + + switch code := respErr.StatusCode; { + case code == http.StatusNotFound: + return fmt.Errorf("%w", puboci.ErrTagAbsent) + case code == http.StatusUnauthorized || code == http.StatusForbidden: + return fmt.Errorf("registry authentication failed: status %d", code) + case code == http.StatusTooManyRequests || code >= http.StatusInternalServerError: + return fmt.Errorf("%w: status %d", puboci.ErrRetryable, code) + default: + return fmt.Errorf("registry request failed: status %d", code) + } +} + +// decodeVersion reads an OCI manifest's version annotation from body. +func decodeVersion(body io.Reader) (rel.Version, error) { + var payload annotationFile + if err := json.NewDecoder(io.LimitReader(body, manifestLimitBytes)).Decode(&payload); err != nil { + return rel.Version{}, fmt.Errorf("%w: manifest is not JSON", puboci.ErrCorruptState) + } + + value := payload.Annotations[ocispec.AnnotationVersion] + if value == "" { + return rel.Version{}, fmt.Errorf("%w: missing %s annotation", puboci.ErrCorruptState, ocispec.AnnotationVersion) + } + + version, err := rel.ParseVersion(value) + if err != nil { + return rel.Version{}, fmt.Errorf("%w: %w", puboci.ErrCorruptState, err) + } + + return version, nil +} diff --git a/internal/adapter/reg/state_test.go b/internal/adapter/reg/state_test.go new file mode 100644 index 0000000..ad3d75c --- /dev/null +++ b/internal/adapter/reg/state_test.go @@ -0,0 +1,297 @@ +package reg + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + + "github.com/google/go-containerregistry/pkg/registry" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + // testRepo is the repository path used by the in-process registry. + testRepo = "owner/image" + // testTag is the fixture tag written by successful cases. + testTag = "1.2.3" + // testVersion is the canonical version annotation written by fixtures. + testVersion = "1.2.3" + // testToken is a credential that must never appear in errors or formats. + testToken = "ghs_this_must_never_appear_in_errors" + // ociSchemaVersion is the OCI image-spec schema version. + ociSchemaVersion = 2 +) + +func TestResolveReturnsManifestDigest(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + body := indexJSON(t, map[string]string{ocispec.AnnotationVersion: testVersion}) + putManifest(t, server, testRepo, testTag, ocispec.MediaTypeImageIndex, body) + + got, err := newPlainClient(server).Resolve(context.Background(), mustRef(t, server)) + require.NoError(t, err) + assert.Equal(t, digestOf(body), got.String()) +} + +func TestResolveMissingTagWrapsErrTagAbsent(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + _, err := newPlainClient(server).Resolve(context.Background(), mustRef(t, server)) + require.Error(t, err) + require.ErrorIs(t, err, puboci.ErrTagAbsent) + assert.NotContains(t, err.Error(), testToken) +} + +func TestVersionReadsAnnotation(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + body := indexJSON(t, map[string]string{ocispec.AnnotationVersion: testVersion}) + putManifest(t, server, testRepo, testTag, ocispec.MediaTypeImageIndex, body) + + got, err := newPlainClient(server).Version(context.Background(), mustRef(t, server)) + require.NoError(t, err) + assert.Equal(t, testVersion, got.String()) +} + +func TestVersionWrapsCorruptManifests(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + contentType string + body []byte + }{ + { + name: "no annotations", + contentType: ocispec.MediaTypeImageIndex, + body: indexJSON(t, nil), + }, + { + name: "empty version annotation", + contentType: ocispec.MediaTypeImageIndex, + body: indexJSON(t, map[string]string{ocispec.AnnotationVersion: ""}), + }, + { + name: "minor-only version", + contentType: ocispec.MediaTypeImageIndex, + body: indexJSON(t, map[string]string{ocispec.AnnotationVersion: "1.2"}), + }, + { + name: "v-prefixed version", + contentType: ocispec.MediaTypeImageIndex, + body: indexJSON(t, map[string]string{ocispec.AnnotationVersion: "v1.2.3"}), + }, + { + name: "prerelease version", + contentType: ocispec.MediaTypeImageIndex, + body: indexJSON(t, map[string]string{ocispec.AnnotationVersion: "1.2.3-rc.1"}), + }, + { + name: "non-JSON body", + contentType: ocispec.MediaTypeImageManifest, + body: []byte("not-json"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + server := newRegistryServer(t) + putManifest(t, server, testRepo, testTag, test.contentType, test.body) + + _, err := newPlainClient(server).Version(context.Background(), mustRef(t, server)) + require.Error(t, err) + require.ErrorIs(t, err, puboci.ErrCorruptState) + }) + } +} + +func TestRegistryStatusClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + wantSent error + wantText string + }{ + { + name: "service unavailable", + status: http.StatusServiceUnavailable, + wantSent: puboci.ErrRetryable, + wantText: "retryable", + }, + { + name: "too many requests", + status: http.StatusTooManyRequests, + wantSent: puboci.ErrRetryable, + wantText: "retryable", + }, + { + name: "unauthorized", + status: http.StatusUnauthorized, + wantText: "registry authentication failed", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(test.status) + })) + t.Cleanup(server.Close) + + client := New(Options{ + Credentials: Credentials{ + Username: "octocat", + Password: rel.NewSecret(testToken), + }, + PlainHTTP: true, + HTTPClient: server.Client(), + }) + _, err := client.Resolve(context.Background(), mustRef(t, server)) + require.Error(t, err) + if test.wantSent != nil { + require.ErrorIs(t, err, test.wantSent) + } else { + require.NotErrorIs(t, err, puboci.ErrRetryable) + require.NotErrorIs(t, err, puboci.ErrTagAbsent) + } + assert.Contains(t, err.Error(), test.wantText) + assert.NotContains(t, err.Error(), testToken) + assert.NotContains(t, err.Error(), "Authorization") + assert.NotContains(t, err.Error(), server.URL) + }) + } +} + +func TestCanceledContextDoesNotSucceed(t *testing.T) { + t.Parallel() + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + hits.Add(1) + writer.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := newPlainClient(server).Resolve(ctx, mustRef(t, server)) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + assert.Zero(t, hits.Load()) + assert.NotContains(t, err.Error(), server.URL) + assert.NotContains(t, err.Error(), testToken) +} + +func TestClientFormatOmitsToken(t *testing.T) { + t.Parallel() + + client := New(Options{Credentials: Credentials{ + Username: "octocat", + Password: rel.NewSecret(testToken), + }}) + assert.NotContains(t, fmt.Sprintf("%v", client), testToken) + assert.NotContains(t, fmt.Sprintf("%+v", client), testToken) +} + +// newRegistryServer starts an in-process OCI registry. +func newRegistryServer(t *testing.T) *httptest.Server { + t.Helper() + + server := httptest.NewServer(registry.New()) + t.Cleanup(server.Close) + + return server +} + +// newPlainClient returns an anonymous client pointed at server over HTTP. +func newPlainClient(server *httptest.Server) *Client { + return New(Options{ + PlainHTTP: true, + HTTPClient: server.Client(), + }) +} + +// putManifest writes body as a tagged manifest on the fake registry. +func putManifest(t *testing.T, server *httptest.Server, repo, tag, contentType string, body []byte) { + t.Helper() + + req, err := http.NewRequest( + http.MethodPut, + server.URL+"/v2/"+repo+"/manifests/"+tag, + bytes.NewReader(body), + ) + require.NoError(t, err) + req.Header.Set("Content-Type", contentType) + + resp, err := server.Client().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + require.Equal(t, http.StatusCreated, resp.StatusCode) +} + +// indexJSON encodes a minimal OCI image index with the given annotations. +func indexJSON(t *testing.T, annotations map[string]string) []byte { + t.Helper() + + payload := struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Manifests []struct{} `json:"manifests"` + Annotations map[string]string `json:"annotations,omitempty"` + }{ + SchemaVersion: ociSchemaVersion, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []struct{}{}, + Annotations: annotations, + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + return body +} + +// digestOf returns the sha256: digest of body. +func digestOf(body []byte) string { + sum := sha256.Sum256(body) + + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// mustRef builds the fixture reference for testRepo and testTag on server. +func mustRef(t *testing.T, server *httptest.Server) puboci.Reference { + t.Helper() + + parsed, err := url.Parse(server.URL) + require.NoError(t, err) + image, err := puboci.ParseImage(parsed.Host + "/" + testRepo) + require.NoError(t, err) + tag, err := rel.ParseTag(testTag) + require.NoError(t, err) + + return image.Reference(tag) +} diff --git a/internal/cli/doc.go b/internal/cli/doc.go index 3ea39d1..aa11783 100644 --- a/internal/cli/doc.go +++ b/internal/cli/doc.go @@ -1,8 +1,9 @@ // Package cli implements the release-cli Cobra command tree. // // NewRootCommand builds a fresh command with injected streams and an optional -// [LookupEnv] seam. The tree exposes stage, verify handoff, and version. -// Flags override RELEASE_* environment variables via [cobra.Flag.Changed]; -// there is no config file. ExitCode maps errors onto the process contract: -// 0 success, 1 verification failure, 2 usage or configuration error. +// [LookupEnv] seam. The tree exposes stage, plan tags, verify handoff, and +// version. Flags override RELEASE_* environment variables via +// [cobra.Flag.Changed]; there is no config file. ExitCode maps errors onto +// the process contract: 0 success, 1 verification failure, 2 usage or +// configuration error. package cli diff --git a/internal/cli/root.go b/internal/cli/root.go index b4b2e6b..e9b3e1d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/meigma/release/internal/stage/pubgh" + "github.com/meigma/release/internal/stage/puboci" ) const ( @@ -22,6 +23,10 @@ const ( envDist = "RELEASE_DIST" // envJSON is the environment variable for --json. envJSON = "RELEASE_JSON" + // envImage is the environment variable for --image. + envImage = "RELEASE_IMAGE" + // envVersion is the environment variable for --version. + envVersion = "RELEASE_VERSION" ) // LookupEnv looks up an environment variable. @@ -41,6 +46,10 @@ type Settings struct { Dist string // ArtifactID is the selected --artifact-id / RELEASE_ARTIFACT_ID value. ArtifactID string + // Image is the selected --image / RELEASE_IMAGE value. + Image string + // Version is the selected --version / RELEASE_VERSION value. + Version string // Digest is the selected --digest / RELEASE_DIGEST value. Digest string // JSON reports whether --json / RELEASE_JSON requested structured output. @@ -73,6 +82,10 @@ type Options struct { ArtifactMeta pubgh.ArtifactMeta // NewArtifactMeta constructs the metadata port from a token and API endpoint. NewArtifactMeta func(token string, endpoint GitHubEndpoint) (pubgh.ArtifactMeta, error) + // StateReader, when set, is the registry read port. Tests inject it. + StateReader puboci.StateReader + // NewStateReader constructs the registry read port from resolved credentials. + NewStateReader func(credentials RegistryCredentials) (puboci.StateReader, error) // settings is filled after flags are parsed. settings *Settings } @@ -108,6 +121,7 @@ func NewRootCommand(options Options) *cobra.Command { root.SetFlagErrorFunc(flagParseError) root.PersistentFlags().Bool("json", false, "emit one JSON result document on stdout") root.AddCommand(newStageCommand(options)) + root.AddCommand(newPlanCommand(options)) root.AddCommand(newVerifyCommand(options)) root.AddCommand(newVersionCommand(options)) @@ -157,6 +171,8 @@ func resolveSettings(cmd *cobra.Command, lookup LookupEnv) Settings { Profile: resolveString(cmd, flagProfile, envProfile, lookup), Dist: resolveString(cmd, flagDist, envDist, lookup), ArtifactID: resolveString(cmd, flagArtifactID, envArtifactID, lookup), + Image: resolveString(cmd, flagImage, envImage, lookup), + Version: resolveString(cmd, flagVersion, envVersion, lookup), Digest: resolveString(cmd, flagDigest, envDigest, lookup), JSON: resolveBool(cmd, "json", envJSON, lookup), } diff --git a/internal/cli/tags.go b/internal/cli/tags.go new file mode 100644 index 0000000..2622115 --- /dev/null +++ b/internal/cli/tags.go @@ -0,0 +1,301 @@ +package cli + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + // commandPlanTags is the envelope command path for plan tags. + commandPlanTags = "plan tags" + // flagImage is the plan-tags image flag name. + flagImage = "image" + // flagVersion is the plan-tags version flag name. + flagVersion = "version" + // envRefName is the Actions git-ref name used as a version default. + envRefName = "GITHUB_REF_NAME" + // envActor is the Actions actor used as a registry username. + envActor = "GITHUB_ACTOR" + // defaultImageRegistry is the registry host used when deriving --image. + defaultImageRegistry = "ghcr.io" + // defaultRegistryUser is the GHCR username used when a token is present + // but GITHUB_ACTOR is unset. + defaultRegistryUser = "x-access-token" +) + +// PlanTagsResult is the --json payload for plan tags. +type PlanTagsResult struct { + // Image is the OCI image whose tags were inspected. + Image string `json:"image"` + // Version is the candidate stable release version. + Version string `json:"version"` + // Digest is the candidate OCI index digest. + Digest string `json:"digest"` + // Tags are the tags with a create decision, in decision order. + Tags []string `json:"tags"` + // Decisions are the exact tag and each channel, in policy order. + Decisions []TagDecisionResult `json:"decisions"` +} + +// TagDecisionResult is one planned tag action in a [PlanTagsResult]. +type TagDecisionResult struct { + // Tag is the exact or channel tag that was evaluated. + Tag string `json:"tag"` + // Scope is the tag scope: exact, minor, major, or latest. + Scope string `json:"scope"` + // Action is the planned outcome: create, accept, or retain. + Action string `json:"action"` +} + +// RegistryCredentials is the resolved registry username and password. +// +// An empty Password selects an anonymous read. Username is meaningful only +// when Password is set. +type RegistryCredentials struct { + // Username is GITHUB_ACTOR, or x-access-token when a token is present. + Username string + // Password is the token from GITHUB_TOKEN or GH_TOKEN. + Password rel.Secret +} + +// newPlanCommand constructs the plan parent verb. +func newPlanCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "plan", + Short: "Plan release publication steps", + Args: requireSubcommand, + RunE: func(_ *cobra.Command, _ []string) error { + return UsageError(errors.New("a plan subcommand is required")) + }, + } + cmd.AddCommand(newTagsCommand(options)) + + return cmd +} + +// newTagsCommand constructs the plan tags verb. +func newTagsCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "tags", + Short: "Plan immutable exact tags and moving channel tags", + Args: usageNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPlanTags(cmd, options) + }, + } + cmd.Flags().String(flagImage, "", "OCI image name without a tag or digest") + cmd.Flags().String(flagVersion, "", "stable MAJOR.MINOR.PATCH version") + cmd.Flags().String(flagDigest, "", "candidate OCI index digest") + + return cmd +} + +// runPlanTags validates configuration and plans immutable release tags. +// +// Missing or malformed configuration is [ErrUsage] and is raised before the +// registry port is constructed or called. A planning or registry failure is +// returned as a command failure. Success without --json writes nothing. +func runPlanTags(cmd *cobra.Command, options Options) error { + expected, err := resolveTags(options) + if err != nil { + return writeCommandResult(options, commandPlanTags, nil, UsageError(err)) + } + + reader, err := stateReader(options, expected.Credentials) + if err != nil { + return writeCommandResult(options, commandPlanTags, nil, err) + } + + plan, err := puboci.PlanTags( + cmd.Context(), + reader, + expected.Image, + expected.Version, + expected.Digest, + ) + if err != nil { + return writeCommandResult(options, commandPlanTags, nil, err) + } + if options.settings == nil || !options.settings.JSON { + return nil + } + + return writeCommandResult(options, commandPlanTags, planTagsResult(expected, plan), nil) +} + +// tagsConfig is the resolved plan-tags configuration. +type tagsConfig struct { + // Image is the untagged repository to inspect. + Image puboci.Image + // Version is the candidate stable release version. + Version rel.Version + // Digest is the candidate image digest. + Digest rel.Digest + // Credentials authenticates registry reads. An empty password is anonymous. + Credentials RegistryCredentials +} + +// resolveTags parses flags and Actions environment into a plan-tags config. +// +// It performs no network I/O. +func resolveTags(options Options) (tagsConfig, error) { + settings := Settings{} + if options.settings != nil { + settings = *options.settings + } + + digest, err := resolvePlanDigest(settings) + if err != nil { + return tagsConfig{}, err + } + image, err := resolvePlanImage(settings, options.LookupEnv) + if err != nil { + return tagsConfig{}, err + } + version, err := resolvePlanVersion(settings, options.LookupEnv) + if err != nil { + return tagsConfig{}, err + } + + return tagsConfig{ + Image: image, + Version: version, + Digest: digest, + Credentials: resolveRegistryCredentials(options.LookupEnv), + }, nil +} + +// resolvePlanDigest requires and parses --digest / RELEASE_DIGEST. +func resolvePlanDigest(settings Settings) (rel.Digest, error) { + if settings.Digest == "" { + return "", fmt.Errorf("--%s is required", flagDigest) + } + + return rel.ParseDigest(settings.Digest) +} + +// resolvePlanImage returns --image / RELEASE_IMAGE, or a derived GHCR name. +func resolvePlanImage(settings Settings, lookup LookupEnv) (puboci.Image, error) { + raw := settings.Image + if raw == "" { + derived, err := deriveImage(lookup) + if err != nil { + return "", err + } + raw = derived + } + + return puboci.ParseImage(raw) +} + +// resolvePlanVersion returns --version / RELEASE_VERSION, or GITHUB_REF_NAME. +func resolvePlanVersion(settings Settings, lookup LookupEnv) (rel.Version, error) { + raw := settings.Version + if raw == "" { + derived, err := deriveVersion(lookup) + if err != nil { + return rel.Version{}, err + } + raw = derived + } + + return rel.ParseVersion(raw) +} + +// deriveImage builds ghcr.io// from GITHUB_REPOSITORY. +func deriveImage(lookup LookupEnv) (string, error) { + if lookup == nil { + return "", fmt.Errorf("--%s is required when %s is unset", flagImage, envRepository) + } + repository, ok := lookup(envRepository) + if !ok || repository == "" { + return "", fmt.Errorf("--%s is required when %s is unset", flagImage, envRepository) + } + + return strings.ToLower(defaultImageRegistry + "/" + repository), nil +} + +// deriveVersion reads GITHUB_REF_NAME and strips one optional leading v. +func deriveVersion(lookup LookupEnv) (string, error) { + if lookup == nil { + return "", fmt.Errorf("--%s is required when %s is unset", flagVersion, envRefName) + } + refName, ok := lookup(envRefName) + if !ok || refName == "" { + return "", fmt.Errorf("--%s is required when %s is unset", flagVersion, envRefName) + } + + return strings.TrimPrefix(refName, "v"), nil +} + +// resolveRegistryCredentials reads the optional Actions registry token. +// +// A missing token yields empty credentials and is not an error. +func resolveRegistryCredentials(lookup LookupEnv) RegistryCredentials { + token := resolveToken(lookup) + if token == "" { + return RegistryCredentials{} + } + + username := defaultRegistryUser + if lookup != nil { + if value, ok := lookup(envActor); ok && value != "" { + username = value + } + } + + return RegistryCredentials{ + Username: username, + Password: rel.NewSecret(token), + } +} + +// stateReader returns the injected port or constructs one from credentials. +func stateReader(options Options, credentials RegistryCredentials) (puboci.StateReader, error) { + if options.StateReader != nil { + return options.StateReader, nil + } + if options.NewStateReader == nil { + return nil, errors.New("state reader factory is not configured") + } + + reader, err := options.NewStateReader(credentials) + if err != nil { + return nil, UsageError(fmt.Errorf("registry client: %w", err)) + } + if reader == nil { + return nil, errors.New("state reader factory returned nil") + } + + return reader, nil +} + +// planTagsResult builds the success envelope payload. +func planTagsResult(expected tagsConfig, plan rel.TagPlan) PlanTagsResult { + applied := plan.Apply() + result := PlanTagsResult{ + Image: expected.Image.String(), + Version: expected.Version.String(), + Digest: expected.Digest.String(), + Tags: make([]string, 0, len(applied)), + Decisions: make([]TagDecisionResult, 0, len(plan.Decisions)), + } + for _, tag := range applied { + result.Tags = append(result.Tags, tag.String()) + } + for _, decision := range plan.Decisions { + result.Decisions = append(result.Decisions, TagDecisionResult{ + Tag: decision.Tag.String(), + Scope: string(decision.Scope), + Action: string(decision.Action), + }) + } + + return result +} diff --git a/internal/cli/tags_test.go b/internal/cli/tags_test.go new file mode 100644 index 0000000..672c007 --- /dev/null +++ b/internal/cli/tags_test.go @@ -0,0 +1,517 @@ +package cli_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + regmocks "github.com/meigma/release/internal/adapter/reg/mocks" + "github.com/meigma/release/internal/cli" + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + tagsDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + tagsOther = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + tagsToken = "ghs_should_never_appear" + tagsImage = "ghcr.io/owner/repo" +) + +func TestPlanTagsMissingDigestIsUsage(t *testing.T) { + t.Parallel() + + called := false + stdout, err := executeTagsFactory(t, map[string]string{ + "RELEASE_IMAGE": tagsImage, + "RELEASE_VERSION": "1.2.3", + }, []string{"plan", "tags"}, func(cli.RegistryCredentials) (puboci.StateReader, error) { + called = true + return unusedReader(t), nil + }) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stdout) + assert.Contains(t, err.Error(), "--digest is required") + assert.False(t, called) +} + +func TestPlanTagsMalformedValuesAreUsage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + env map[string]string + args []string + want string + }{ + { + name: "malformed digest", + args: []string{"plan", "tags", "--image", tagsImage, "--version", "1.2.3", "--digest", "not-a-digest"}, + want: "digest", + }, + { + name: "malformed version", + args: []string{"plan", "tags", "--image", tagsImage, "--version", "v1.2.3", "--digest", tagsDigest}, + want: "v prefix", + }, + { + name: "malformed image", + args: []string{ + "plan", "tags", + "--image", "GHCR.IO/OWNER/REPO", + "--version", "1.2.3", + "--digest", tagsDigest, + }, + want: "uppercase", + }, + { + name: "missing repository without image", + env: map[string]string{"GITHUB_REF_NAME": "v1.2.3"}, + args: []string{"plan", "tags", "--digest", tagsDigest}, + want: "GITHUB_REPOSITORY", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + called := false + stdout, err := executeTagsFactory( + t, + tt.env, + tt.args, + func(cli.RegistryCredentials) (puboci.StateReader, error) { + called = true + return unusedReader(t), nil + }, + ) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.Empty(t, stdout) + assert.Contains(t, err.Error(), tt.want) + assert.False(t, called) + }) + } +} + +func TestPlanTagsDerivedDefaults(t *testing.T) { + t.Parallel() + + var got []puboci.Reference + reader := regmocks.NewMockStateReader(t) + reader.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Run(func(_ context.Context, ref puboci.Reference) { + got = append(got, ref) + }). + Return(rel.Digest(""), puboci.ErrTagAbsent). + Times(4) + + _, _, err := executeTags(t, map[string]string{ + "GITHUB_REPOSITORY": "Owner/Repo", + "GITHUB_REF_NAME": "v1.2.3", + }, []string{"plan", "tags", "--digest", tagsDigest}, reader) + require.NoError(t, err) + require.NotEmpty(t, got) + assert.Equal(t, tagsImage, got[0].Image.String()) + assert.Equal(t, []string{"1.2.3", "1.2", "1", "latest"}, referenceTags(got)) +} + +func TestPlanTagsFlagOverridesEnv(t *testing.T) { + t.Parallel() + + var got []puboci.Reference + reader := regmocks.NewMockStateReader(t) + reader.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Run(func(_ context.Context, ref puboci.Reference) { + got = append(got, ref) + }). + Return(rel.Digest(""), puboci.ErrTagAbsent). + Times(4) + + _, _, err := executeTags(t, map[string]string{ + "GITHUB_REPOSITORY": "Owner/Repo", + "GITHUB_REF_NAME": "v9.9.9", + "RELEASE_IMAGE": "ghcr.io/env/image", + "RELEASE_VERSION": "9.9.9", + "RELEASE_DIGEST": tagsOther, + }, []string{ + "plan", "tags", + "--image", "ghcr.io/flag/image", + "--version", "1.2.3", + "--digest", tagsDigest, + }, reader) + require.NoError(t, err) + require.NotEmpty(t, got) + assert.Equal(t, "ghcr.io/flag/image", got[0].Image.String()) + assert.Equal(t, []string{"1.2.3", "1.2", "1", "latest"}, referenceTags(got)) +} + +func TestPlanTagsSilentSuccessWithoutJSON(t *testing.T) { + t.Parallel() + + stdout, stderr, err := executeTags(t, nil, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + }, absentReader(t)) + require.NoError(t, err) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestPlanTagsJSONSuccess(t *testing.T) { + t.Parallel() + + stdout, stderr, err := executeTags(t, nil, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + "--json", + }, absentReader(t)) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, 1, countJSONDocuments(stdout)) + assert.NotContains(t, stdout, tagsToken) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, "plan tags", envelope.Command) + assert.True(t, envelope.OK) + + result := decodePlanTagsResult(t, envelope) + assert.Equal(t, tagsImage, result.Image) + assert.Equal(t, "1.2.3", result.Version) + assert.Equal(t, tagsDigest, result.Digest) + assert.Equal(t, []string{"1.2.3", "1.2", "1", "latest"}, result.Tags) + assert.Equal(t, []cli.TagDecisionResult{ + {Tag: "1.2.3", Scope: "exact", Action: "create"}, + {Tag: "1.2", Scope: "minor", Action: "create"}, + {Tag: "1", Scope: "major", Action: "create"}, + {Tag: "latest", Scope: "latest", Action: "create"}, + }, result.Decisions) +} + +func TestPlanTagsJSONDocumentedPayload(t *testing.T) { + t.Parallel() + + stdout, stderr, err := executeTags(t, nil, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + "--json", + }, mixedReader(t)) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, 1, countJSONDocuments(stdout)) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, "plan tags", envelope.Command) + assert.True(t, envelope.OK) + + result := decodePlanTagsResult(t, envelope) + assert.Equal(t, cli.PlanTagsResult{ + Image: tagsImage, + Version: "1.2.3", + Digest: tagsDigest, + Tags: []string{"1.2.3", "1.2"}, + Decisions: []cli.TagDecisionResult{ + {Tag: "1.2.3", Scope: "exact", Action: "create"}, + {Tag: "1.2", Scope: "minor", Action: "create"}, + {Tag: "1", Scope: "major", Action: "retain"}, + {Tag: "latest", Scope: "latest", Action: "accept"}, + }, + }, result) +} + +func TestPlanTagsJSONEmptyTags(t *testing.T) { + t.Parallel() + + stdout, stderr, err := executeTags(t, nil, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + "--json", + }, matchingReader(t, tagsDigest)) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, 1, countJSONDocuments(stdout)) + assert.Contains(t, stdout, `"tags":[]`) + assert.NotContains(t, stdout, `"tags":null`) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, "plan tags", envelope.Command) + assert.True(t, envelope.OK) + + result := decodePlanTagsResult(t, envelope) + require.NotNil(t, result.Tags) + assert.Empty(t, result.Tags) + assert.Equal(t, []cli.TagDecisionResult{ + {Tag: "1.2.3", Scope: "exact", Action: "accept"}, + {Tag: "1.2", Scope: "minor", Action: "accept"}, + {Tag: "1", Scope: "major", Action: "accept"}, + {Tag: "latest", Scope: "latest", Action: "accept"}, + }, result.Decisions) +} + +func TestPlanTagsImmutableConflictIsExitOne(t *testing.T) { + t.Parallel() + + stdout, _, err := executeTags(t, map[string]string{ + "GITHUB_TOKEN": tagsToken, + }, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + "--json", + }, conflictReader(t, tagsOther)) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "immutable") + assert.NotContains(t, err.Error(), tagsToken) + assert.Equal(t, 1, countJSONDocuments(stdout)) + assert.Contains(t, stdout, `"command":"plan tags"`) + assert.Contains(t, stdout, `"ok":false`) + assert.Contains(t, stdout, "immutable") + assert.NotContains(t, stdout, tagsToken) +} + +func TestPlanTagsCredentialResolution(t *testing.T) { + t.Parallel() + + t.Run("github token wins and actor is username", func(t *testing.T) { + t.Parallel() + + var got cli.RegistryCredentials + stdout, err := executeTagsFactory(t, map[string]string{ + "GITHUB_TOKEN": tagsToken, + "GH_TOKEN": "ghs_fallback_must_not_win", + "GITHUB_ACTOR": "octocat", + }, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + "--json", + }, func(credentials cli.RegistryCredentials) (puboci.StateReader, error) { + got = credentials + return absentReader(t), nil + }) + require.NoError(t, err) + assert.Equal(t, "octocat", got.Username) + assert.Equal(t, tagsToken, got.Password.Reveal()) + assert.NotContains(t, stdout, tagsToken) + assert.NotContains(t, stdout, "ghs_fallback_must_not_win") + }) + + t.Run("absent token is anonymous", func(t *testing.T) { + t.Parallel() + + var got cli.RegistryCredentials + called := false + _, err := executeTagsFactory(t, nil, []string{ + "plan", "tags", + "--image", tagsImage, + "--version", "1.2.3", + "--digest", tagsDigest, + }, func(credentials cli.RegistryCredentials) (puboci.StateReader, error) { + called = true + got = credentials + return absentReader(t), nil + }) + require.NoError(t, err) + require.True(t, called) + assert.Equal(t, cli.RegistryCredentials{}, got) + assert.True(t, got.Password.IsEmpty()) + }) +} + +// unusedReader returns a generated mock that fails if the port is called. +func unusedReader(t *testing.T) *regmocks.MockStateReader { + t.Helper() + + return regmocks.NewMockStateReader(t) +} + +// absentReader returns ErrTagAbsent for every Resolve call. +func absentReader(t *testing.T) *regmocks.MockStateReader { + t.Helper() + + reader := regmocks.NewMockStateReader(t) + reader.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Return(rel.Digest(""), puboci.ErrTagAbsent). + Times(4) + + return reader +} + +// matchingReader resolves every tag to digest and never reads Version. +func matchingReader(t *testing.T, digest string) *regmocks.MockStateReader { + t.Helper() + + parsed, err := rel.ParseDigest(digest) + require.NoError(t, err) + + reader := regmocks.NewMockStateReader(t) + reader.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Return(parsed, nil). + Times(4) + + return reader +} + +// conflictReader points the exact tag at other and leaves channels absent. +func conflictReader(t *testing.T, other string) *regmocks.MockStateReader { + t.Helper() + + parsed, err := rel.ParseDigest(other) + require.NoError(t, err) + + reader := regmocks.NewMockStateReader(t) + reader.EXPECT(). + Resolve(mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, ref puboci.Reference) (rel.Digest, error) { + if ref.Tag.String() == "1.2.3" { + return parsed, nil + } + + return "", puboci.ErrTagAbsent + }). + Times(4) + + return reader +} + +// mixedReader produces the documented create/create/retain/accept plan. +func mixedReader(t *testing.T) *regmocks.MockStateReader { + t.Helper() + + candidate, err := rel.ParseDigest(tagsDigest) + require.NoError(t, err) + other, err := rel.ParseDigest(tagsOther) + require.NoError(t, err) + newer, err := rel.ParseVersion("1.9.0") + require.NoError(t, err) + + reader := regmocks.NewMockStateReader(t) + reader.EXPECT(). + Resolve(mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, ref puboci.Reference) (rel.Digest, error) { + switch ref.Tag.String() { + case "1": + return other, nil + case "latest": + return candidate, nil + default: + return "", puboci.ErrTagAbsent + } + }). + Times(4) + reader.EXPECT(). + Version(mock.Anything, mock.MatchedBy(func(ref puboci.Reference) bool { + return ref.Tag.String() == "1" + })). + Return(newer, nil). + Once() + + return reader +} + +// executeTags runs plan tags with an injected state reader. +func executeTags( + t *testing.T, + env map[string]string, + args []string, + reader puboci.StateReader, +) (string, string, error) { + t.Helper() + + if env == nil { + env = map[string]string{} + } + + stdout := &strings.Builder{} + stderr := &strings.Builder{} + command := cli.NewRootCommand(cli.Options{ + Out: stdout, + Err: stderr, + LookupEnv: func(key string) (string, bool) { + value, ok := env[key] + return value, ok + }, + StateReader: reader, + }) + command.SetArgs(args) + err := command.Execute() + + return stdout.String(), stderr.String(), err +} + +// executeTagsFactory runs plan tags with a credential-observing factory and +// returns stdout. +func executeTagsFactory( + t *testing.T, + env map[string]string, + args []string, + factory func(cli.RegistryCredentials) (puboci.StateReader, error), +) (string, error) { + t.Helper() + + if env == nil { + env = map[string]string{} + } + + stdout := &strings.Builder{} + command := cli.NewRootCommand(cli.Options{ + Out: stdout, + Err: &strings.Builder{}, + LookupEnv: func(key string) (string, bool) { + value, ok := env[key] + return value, ok + }, + NewStateReader: factory, + }) + command.SetArgs(args) + err := command.Execute() + + return stdout.String(), err +} + +// decodePlanTagsResult unmarshals the envelope result as [cli.PlanTagsResult]. +func decodePlanTagsResult(t *testing.T, envelope cli.Envelope) cli.PlanTagsResult { + t.Helper() + + raw, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result cli.PlanTagsResult + require.NoError(t, json.Unmarshal(raw, &result)) + + return result +} + +// referenceTags returns the tags observed on successive Resolve calls. +func referenceTags(refs []puboci.Reference) []string { + tags := make([]string, 0, len(refs)) + for _, ref := range refs { + tags = append(tags, ref.Tag.String()) + } + + return tags +} diff --git a/internal/rel/digest.go b/internal/rel/digest.go new file mode 100644 index 0000000..a4ad479 --- /dev/null +++ b/internal/rel/digest.go @@ -0,0 +1,69 @@ +package rel + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "unicode" +) + +// digestPrefix is the canonical OCI SHA-256 algorithm prefix. +const digestPrefix = "sha256:" + +// Digest is a lowercase sha256:<64 hex> image digest. +// +// The only constructor is [ParseDigest], which requires the sha256: prefix +// and normalizes uppercase hex. The zero value is invalid. +type Digest string + +// ParseDigest constructs a [Digest] from a sha256: string. +// +// The sha256: prefix is required. The hex part must be exactly +// [hex.EncodedLen] of [sha256.Size] hexadecimal digits. Uppercase hex is +// normalized to lowercase. Any other prefix, length, or charset is rejected. +func ParseDigest(value string) (Digest, error) { + if value == "" { + return "", fmt.Errorf("digest %q is empty", value) + } + + hexPart, found := strings.CutPrefix(value, digestPrefix) + if !found { + return "", digestPrefixError(value) + } + if len(hexPart) != hex.EncodedLen(sha256.Size) { + return "", fmt.Errorf( + "digest %q has %d hex digits, want %d", + value, + len(hexPart), + hex.EncodedLen(sha256.Size), + ) + } + for _, r := range hexPart { + if !isHex(r) { + return "", fmt.Errorf("digest %q is not hexadecimal", value) + } + } + + return Digest(digestPrefix + strings.ToLower(hexPart)), nil +} + +// String returns the canonical sha256: digest. +func (d Digest) String() string { + return string(d) +} + +// digestPrefixError names a missing or unexpected algorithm prefix. +func digestPrefixError(value string) error { + algorithm, rest, found := strings.Cut(value, ":") + if found && algorithm != "" && rest != "" { + return fmt.Errorf("digest %q has prefix %q, want %q", value, algorithm+":", digestPrefix) + } + + return fmt.Errorf("digest %q is missing the %s prefix", value, digestPrefix) +} + +// isHex reports whether r is an ASCII hexadecimal digit. +func isHex(r rune) bool { + return unicode.Is(unicode.ASCII_Hex_Digit, r) +} diff --git a/internal/rel/digest_test.go b/internal/rel/digest_test.go new file mode 100644 index 0000000..9d3b8f0 --- /dev/null +++ b/internal/rel/digest_test.go @@ -0,0 +1,82 @@ +package rel + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + validHexLower = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + validHexUpper = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + validDigest = digestPrefix + validHexLower +) + +func TestParseDigest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr string + }{ + { + name: "valid lowercase", + input: validDigest, + want: validDigest, + }, + { + name: "uppercase hex is normalized", + input: digestPrefix + validHexUpper, + want: validDigest, + }, + { + name: "empty", + input: "", + wantErr: `digest "" is empty`, + }, + { + name: "missing prefix", + input: validHexLower, + wantErr: `digest "` + validHexLower + `" is missing the sha256: prefix`, + }, + { + name: "wrong prefix", + input: "sha512:" + validHexLower, + wantErr: `digest "sha512:` + validHexLower + `" has prefix "sha512:", want "sha256:"`, + }, + { + name: "short hex", + input: digestPrefix + "aaaa", + wantErr: `digest "sha256:aaaa" has 4 hex digits, want 64`, + }, + { + name: "long hex", + input: digestPrefix + validHexLower + "aa", + wantErr: `digest "` + digestPrefix + validHexLower + `aa" has 66 hex digits, want 64`, + }, + { + name: "non hex", + input: digestPrefix + strings.Repeat("z", 64), + wantErr: `digest "` + digestPrefix + strings.Repeat("z", 64) + `" is not hexadecimal`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseDigest(test.input) + if test.wantErr != "" { + require.EqualError(t, err, test.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got.String()) + }) + } +} diff --git a/internal/rel/doc.go b/internal/rel/doc.go new file mode 100644 index 0000000..b962861 --- /dev/null +++ b/internal/rel/doc.go @@ -0,0 +1,6 @@ +// Package rel is the pure release model: versions, digests, tags, and tag plans. +// +// Parse functions construct domain values. [PlanTags] decides which immutable +// exact tag and moving channel tags a candidate release may apply. The package +// performs no I/O and depends only on the standard library. +package rel diff --git a/internal/rel/secret.go b/internal/rel/secret.go new file mode 100644 index 0000000..5aa8186 --- /dev/null +++ b/internal/rel/secret.go @@ -0,0 +1,51 @@ +package rel + +// redacted is the public stand-in for a Secret's contents. +const redacted = "[REDACTED]" + +// Secret holds a credential that must not appear in logs or encodings. +// +// [Secret.Reveal] is the only way to read the payload. String, GoString, and +// the marshal methods always return [redacted], including for an empty value. +type Secret struct { + // value is the secret payload. It must only be read through Reveal. + value string +} + +// NewSecret constructs a [Secret] that wraps value. +func NewSecret(value string) Secret { + return Secret{value: value} +} + +// Reveal returns the wrapped payload. +// +// Callers should use Reveal only at adapter composition edges that need the +// real credential. +func (s Secret) Reveal() string { + return s.value +} + +// IsEmpty reports whether the wrapped payload is empty. +func (s Secret) IsEmpty() bool { + return s.value == "" +} + +// String returns [redacted]. +func (s Secret) String() string { + return redacted +} + +// GoString returns [redacted]. +func (s Secret) GoString() string { + return redacted +} + +// MarshalText returns [redacted]. +func (s Secret) MarshalText() ([]byte, error) { + return []byte(redacted), nil +} + +// MarshalJSON returns the JSON string [redacted]. +func (s Secret) MarshalJSON() ([]byte, error) { + return []byte(`"` + redacted + `"`), nil +} diff --git a/internal/rel/secret_test.go b/internal/rel/secret_test.go new file mode 100644 index 0000000..eba5348 --- /dev/null +++ b/internal/rel/secret_test.go @@ -0,0 +1,59 @@ +package rel + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecretNeverLeaks(t *testing.T) { + t.Parallel() + + const payload = "super-secret-token" + + tests := []struct { + name string + secret Secret + }{ + {name: "populated", secret: NewSecret(payload)}, + {name: "empty", secret: NewSecret("")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, redacted, test.secret.String()) + assert.Equal(t, redacted, test.secret.GoString()) + assert.Equal(t, redacted, fmt.Sprintf("%v", test.secret)) + assert.Equal(t, "token="+redacted, fmt.Sprintf("token=%s", test.secret)) + assert.Equal(t, `"`+redacted+`"`, fmt.Sprintf("%q", test.secret)) + assert.Equal(t, redacted, fmt.Sprintf("%#v", test.secret)) + + text, err := test.secret.MarshalText() + require.NoError(t, err) + assert.Equal(t, []byte(redacted), text) + + payloadJSON, err := json.Marshal(struct { + Token Secret `json:"token"` + }{Token: test.secret}) + require.NoError(t, err) + assert.JSONEq(t, `{"token":"[REDACTED]"}`, string(payloadJSON)) + }) + } +} + +func TestSecretRevealAndIsEmpty(t *testing.T) { + t.Parallel() + + secret := NewSecret("super-secret-token") + assert.Equal(t, "super-secret-token", secret.Reveal()) + assert.False(t, secret.IsEmpty()) + + empty := NewSecret("") + assert.Empty(t, empty.Reveal()) + assert.True(t, empty.IsEmpty()) +} diff --git a/internal/rel/tag.go b/internal/rel/tag.go new file mode 100644 index 0000000..5e29d84 --- /dev/null +++ b/internal/rel/tag.go @@ -0,0 +1,320 @@ +package rel + +import ( + "errors" + "fmt" + "strconv" +) + +const ( + // maxTagLength is the OCI distribution-spec maximum tag length. + maxTagLength = 128 + // channelCount is the number of moving channel tags [ChannelsFor] returns. + channelCount = 3 + // decisionCount is the exact tag plus every channel from [ChannelsFor]. + decisionCount = 1 + channelCount + // tagLatest is the moving latest channel tag. + tagLatest Tag = "latest" +) + +// Sentinel errors returned by [PlanTags]. +var ( + // ErrImmutableTag reports that an exact version tag already points at + // another digest. + ErrImmutableTag = errors.New("immutable tag conflict") + // ErrChannelCorrupt reports missing version annotation, an out-of-line + // channel, or equal versions with different digests. + ErrChannelCorrupt = errors.New("corrupt channel state") + // ErrStateIncomplete reports that a required channel is missing from + // [ChannelState.Channels]. + ErrStateIncomplete = errors.New("incomplete channel state") +) + +// Tag is a validated OCI image tag. +// +// The only constructor is [ParseTag]. The zero value is invalid. +type Tag string + +// ParseTag constructs a [Tag] from an OCI tag string. +// +// The grammar is [A-Za-z0-9_][A-Za-z0-9._-]{0,127}: the first character is +// alphanumeric or underscore, remaining characters may also be dot or hyphen, +// and the total length is 1 through 128. +func ParseTag(value string) (Tag, error) { + if value == "" { + return "", fmt.Errorf("tag %q is empty", value) + } + if len(value) > maxTagLength { + return "", fmt.Errorf("tag %q has length %d, want at most %d", value, len(value), maxTagLength) + } + if !isTagStart(rune(value[0])) { + return "", fmt.Errorf("tag %q has an invalid leading character", value) + } + for _, r := range value[1:] { + if !isTagRest(r) { + return "", fmt.Errorf("tag %q has an invalid character", value) + } + } + + return Tag(value), nil +} + +// String returns the tag text. +func (t Tag) String() string { + return string(t) +} + +// Scope classifies a planned tag as exact or as a moving channel. +type Scope string + +const ( + // ScopeExact is the immutable MAJOR.MINOR.PATCH tag. + ScopeExact Scope = "exact" + // ScopeMinor is the MAJOR.MINOR channel. + ScopeMinor Scope = "minor" + // ScopeMajor is the MAJOR channel. + ScopeMajor Scope = "major" + // ScopeLatest is the latest channel. + ScopeLatest Scope = "latest" +) + +// Channel is one moving tag together with its advancement rule. +type Channel struct { + // Scope is the channel's advancement rule. + Scope Scope + // Tag is the registry tag for this channel. + Tag Tag +} + +// ChannelsFor returns the moving channels for v, in planning order. +// +// The result is always minor "MAJOR.MINOR", major "MAJOR", then latest +// "latest". Tags are formatted from decimal version components or the +// literal "latest", which cannot produce an invalid OCI tag. +func ChannelsFor(v Version) []Channel { + return []Channel{ + {Scope: ScopeMinor, Tag: decimalTag(v.Major, v.Minor)}, + {Scope: ScopeMajor, Tag: decimalTag(v.Major)}, + {Scope: ScopeLatest, Tag: tagLatest}, + } +} + +// TagState is the observed registry state of one tag. +type TagState struct { + // Present reports whether the tag currently resolves. + Present bool + // Digest is the resolved digest. It is meaningful only when Present. + Digest Digest + // HasVersion reports whether a version annotation was read. + HasVersion bool + // Version is the annotated version. It is meaningful only when HasVersion. + Version Version +} + +// ChannelState is the observed state of the exact tag and every channel. +type ChannelState struct { + // Exact is the observed state of the candidate's exact version tag. + Exact TagState + // Channels is the observed state of each moving channel. Every channel + // from [ChannelsFor] must be present as a key. + Channels map[Channel]TagState +} + +// Action is the planned outcome for one tag. +type Action string + +const ( + // ActionCreate means the tag must be applied to the candidate digest. + ActionCreate Action = "create" + // ActionAccept means the tag already resolves to the candidate digest. + ActionAccept Action = "accept" + // ActionRetain means the channel stays on a newer release. + ActionRetain Action = "retain" +) + +// Decision is the planned action for one tag. +type Decision struct { + // Tag is the registry tag this decision applies to. + Tag Tag + // Scope is the tag's classification. + Scope Scope + // Action is the planned outcome. + Action Action +} + +// TagPlan is the complete set of tag decisions for one candidate. +type TagPlan struct { + // Version is the candidate release version. + Version Version + // Digest is the candidate image digest. + Digest Digest + // Decisions are the exact tag and each channel, in planning order. + Decisions []Decision +} + +// Apply returns the tags that must be written, in decision order. +func (p TagPlan) Apply() []Tag { + tags := make([]Tag, 0, len(p.Decisions)) + for _, decision := range p.Decisions { + if decision.Action == ActionCreate { + tags = append(tags, decision.Tag) + } + } + + return tags +} + +// PlanTags decides which tags a candidate release may apply. +// +// The exact tag is decided first, then each channel from [ChannelsFor] in +// order. A zero digest is rejected. A channel missing from current.Channels +// is [ErrStateIncomplete]. An exact tag on another digest is +// [ErrImmutableTag]. A missing version annotation, an out-of-line channel, +// and equal versions with different digests are [ErrChannelCorrupt]. +func PlanTags(v Version, digest Digest, current ChannelState) (TagPlan, error) { + if digest == "" { + return TagPlan{}, errors.New("digest is empty") + } + + decisions := make([]Decision, 0, decisionCount) + exact, err := planExact(v, digest, current.Exact) + if err != nil { + return TagPlan{}, err + } + decisions = append(decisions, exact) + + for _, channel := range ChannelsFor(v) { + state, ok := current.Channels[channel] + if !ok { + return TagPlan{}, fmt.Errorf("channel %s is missing: %w", channel.Tag, ErrStateIncomplete) + } + decision, err := planChannel(v, digest, channel, state) + if err != nil { + return TagPlan{}, err + } + decisions = append(decisions, decision) + } + + return TagPlan{Version: v, Digest: digest, Decisions: decisions}, nil +} + +// planExact decides the immutable exact-version tag. +func planExact(v Version, digest Digest, state TagState) (Decision, error) { + tag := v.Tag() + if !state.Present { + return Decision{Tag: tag, Scope: ScopeExact, Action: ActionCreate}, nil + } + if state.Digest == digest { + return Decision{Tag: tag, Scope: ScopeExact, Action: ActionAccept}, nil + } + + return Decision{}, fmt.Errorf( + "immutable tag %s resolves to %s; expected %s: %w", + tag, + state.Digest, + digest, + ErrImmutableTag, + ) +} + +// planChannel decides one moving channel tag. +func planChannel(v Version, digest Digest, channel Channel, state TagState) (Decision, error) { + if !state.Present { + return Decision{Tag: channel.Tag, Scope: channel.Scope, Action: ActionCreate}, nil + } + if state.Digest == digest { + return Decision{Tag: channel.Tag, Scope: channel.Scope, Action: ActionAccept}, nil + } + if !state.HasVersion { + return Decision{}, fmt.Errorf( + "channel %s resolves to %s with no version annotation; expected %s: %w", + channel.Tag, + state.Digest, + digest, + ErrChannelCorrupt, + ) + } + if err := checkChannelLine(v, digest, channel, state); err != nil { + return Decision{}, err + } + + switch comparison := v.Compare(state.Version); { + case comparison > 0: + return Decision{Tag: channel.Tag, Scope: channel.Scope, Action: ActionCreate}, nil + case comparison < 0: + return Decision{Tag: channel.Tag, Scope: channel.Scope, Action: ActionRetain}, nil + default: + return Decision{}, fmt.Errorf( + "channel %s has version %s but resolves to %s; expected %s: %w", + channel.Tag, + state.Version, + state.Digest, + digest, + ErrChannelCorrupt, + ) + } +} + +// checkChannelLine rejects a channel that points outside its release line. +func checkChannelLine(candidate Version, digest Digest, channel Channel, state TagState) error { + switch channel.Scope { + case ScopeMinor: + if state.Version.Major != candidate.Major || state.Version.Minor != candidate.Minor { + return fmt.Errorf( + "channel %s points outside its minor release line: %s resolves to %s; expected %s: %w", + channel.Tag, + state.Version, + state.Digest, + digest, + ErrChannelCorrupt, + ) + } + case ScopeMajor: + if state.Version.Major != candidate.Major { + return fmt.Errorf( + "channel %s points outside its major release line: %s resolves to %s; expected %s: %w", + channel.Tag, + state.Version, + state.Digest, + digest, + ErrChannelCorrupt, + ) + } + case ScopeLatest, ScopeExact: + } + + return nil +} + +// decimalTag formats unsigned integers as a dotted OCI tag. +// +// Decimal digits always start with [0-9] and remaining characters are digits +// or dots, so [ParseTag] cannot fail. +func decimalTag(parts ...uint64) Tag { + tag := Tag(strconv.FormatUint(parts[0], 10)) + for _, part := range parts[1:] { + tag += Tag("." + strconv.FormatUint(part, 10)) + } + + return tag +} + +// isTagStart reports whether r may begin an OCI tag. +func isTagStart(r rune) bool { + return isASCIILetter(r) || isASCIIDigit(r) || r == '_' +} + +// isTagRest reports whether r may appear after the first OCI tag character. +func isTagRest(r rune) bool { + return isTagStart(r) || r == '.' || r == '-' +} + +// isASCIILetter reports whether r is an ASCII letter. +func isASCIILetter(r rune) bool { + return r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' +} + +// isASCIIDigit reports whether r is an ASCII decimal digit. +func isASCIIDigit(r rune) bool { + return r >= '0' && r <= '9' +} diff --git a/internal/rel/tag_test.go b/internal/rel/tag_test.go new file mode 100644 index 0000000..e3122b8 --- /dev/null +++ b/internal/rel/tag_test.go @@ -0,0 +1,374 @@ +package rel + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const otherHex = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +func TestParseTag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr string + }{ + {name: "single letter", input: "v", want: "v"}, + {name: "single digit", input: "1", want: "1"}, + {name: "underscore start", input: "_canary", want: "_canary"}, + {name: "dotted version", input: "1.2.3", want: "1.2.3"}, + {name: "inner hyphen and underscore", input: "release-1_2.3", want: "release-1_2.3"}, + {name: "max length", input: strings.Repeat("a", maxTagLength), want: strings.Repeat("a", maxTagLength)}, + {name: "empty", input: "", wantErr: `tag "" is empty`}, + {name: "leading hyphen", input: "-latest", wantErr: `tag "-latest" has an invalid leading character`}, + {name: "leading dot", input: ".1", wantErr: `tag ".1" has an invalid leading character`}, + {name: "inner slash", input: "rel/1", wantErr: `tag "rel/1" has an invalid character`}, + { + name: "too long", + input: strings.Repeat("a", maxTagLength+1), + wantErr: `tag "` + strings.Repeat("a", maxTagLength+1) + `" has length 129, want at most 128`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseTag(test.input) + if test.wantErr != "" { + require.EqualError(t, err, test.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got.String()) + }) + } +} + +func TestChannelsFor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version Version + want []Channel + }{ + { + name: "zero", + version: Version{}, + want: []Channel{ + {Scope: ScopeMinor, Tag: "0.0"}, + {Scope: ScopeMajor, Tag: "0"}, + {Scope: ScopeLatest, Tag: "latest"}, + }, + }, + { + name: "stable", + version: Version{Major: 1, Minor: 2, Patch: 3}, + want: []Channel{ + {Scope: ScopeMinor, Tag: "1.2"}, + {Scope: ScopeMajor, Tag: "1"}, + {Scope: ScopeLatest, Tag: "latest"}, + }, + }, + { + name: "multi-digit", + version: Version{Major: 10, Minor: 20, Patch: 30}, + want: []Channel{ + {Scope: ScopeMinor, Tag: "10.20"}, + {Scope: ScopeMajor, Tag: "10"}, + {Scope: ScopeLatest, Tag: "latest"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.want, ChannelsFor(test.version)) + }) + } +} + +func TestPlanTags(t *testing.T) { + t.Parallel() + + candidate := Version{Major: 1, Minor: 2, Patch: 3} + digest := mustDigest(t, validDigest) + other := mustDigest(t, digestPrefix+otherHex) + olderSameLine := Version{Major: 1, Minor: 2, Patch: 2} + newerSameLine := Version{Major: 1, Minor: 2, Patch: 4} + otherMinor := Version{Major: 1, Minor: 3, Patch: 0} + otherMajor := Version{Major: 2, Minor: 0, Patch: 0} + olderMajor := Version{Major: 0, Minor: 9, Patch: 0} + + tests := []struct { + name string + version Version + digest Digest + state ChannelState + want []Decision + wantApply []Tag + wantErr error + }{ + { + name: "exact and channels absent", + version: candidate, + digest: digest, + state: emptyState(candidate), + want: []Decision{ + {Tag: "1.2.3", Scope: ScopeExact, Action: ActionCreate}, + {Tag: "1.2", Scope: ScopeMinor, Action: ActionCreate}, + {Tag: "1", Scope: ScopeMajor, Action: ActionCreate}, + {Tag: "latest", Scope: ScopeLatest, Action: ActionCreate}, + }, + wantApply: []Tag{"1.2.3", "1.2", "1", "latest"}, + }, + { + name: "exact already the candidate digest", + version: candidate, + digest: digest, + state: withExact(emptyState(candidate), TagState{ + Present: true, + Digest: digest, + }), + want: []Decision{ + {Tag: "1.2.3", Scope: ScopeExact, Action: ActionAccept}, + {Tag: "1.2", Scope: ScopeMinor, Action: ActionCreate}, + {Tag: "1", Scope: ScopeMajor, Action: ActionCreate}, + {Tag: "latest", Scope: ScopeLatest, Action: ActionCreate}, + }, + wantApply: []Tag{"1.2", "1", "latest"}, + }, + { + name: "exact on another digest", + version: candidate, + digest: digest, + state: withExact(emptyState(candidate), TagState{ + Present: true, + Digest: other, + }), + wantErr: ErrImmutableTag, + }, + { + name: "every channel already the candidate digest", + version: candidate, + digest: digest, + state: ChannelState{ + Exact: TagState{Present: true, Digest: digest}, + Channels: map[Channel]TagState{ + {Scope: ScopeMinor, Tag: "1.2"}: {Present: true, Digest: digest}, + {Scope: ScopeMajor, Tag: "1"}: {Present: true, Digest: digest}, + {Scope: ScopeLatest, Tag: "latest"}: {Present: true, Digest: digest}, + }, + }, + want: []Decision{ + {Tag: "1.2.3", Scope: ScopeExact, Action: ActionAccept}, + {Tag: "1.2", Scope: ScopeMinor, Action: ActionAccept}, + {Tag: "1", Scope: ScopeMajor, Action: ActionAccept}, + {Tag: "latest", Scope: ScopeLatest, Action: ActionAccept}, + }, + wantApply: []Tag{}, + }, + { + name: "newer candidate moves every channel", + version: candidate, + digest: digest, + state: annotatedState(candidate, other, olderSameLine), + want: []Decision{ + {Tag: "1.2.3", Scope: ScopeExact, Action: ActionCreate}, + {Tag: "1.2", Scope: ScopeMinor, Action: ActionCreate}, + {Tag: "1", Scope: ScopeMajor, Action: ActionCreate}, + {Tag: "latest", Scope: ScopeLatest, Action: ActionCreate}, + }, + wantApply: []Tag{"1.2.3", "1.2", "1", "latest"}, + }, + { + name: "older candidate retains every channel", + version: candidate, + digest: digest, + state: annotatedState(candidate, other, newerSameLine), + want: []Decision{ + {Tag: "1.2.3", Scope: ScopeExact, Action: ActionCreate}, + {Tag: "1.2", Scope: ScopeMinor, Action: ActionRetain}, + {Tag: "1", Scope: ScopeMajor, Action: ActionRetain}, + {Tag: "latest", Scope: ScopeLatest, Action: ActionRetain}, + }, + wantApply: []Tag{"1.2.3"}, + }, + { + name: "equal version different digest is corrupt", + version: candidate, + digest: digest, + state: annotatedState(candidate, other, candidate), + wantErr: ErrChannelCorrupt, + }, + { + name: "channel present without version annotation", + version: candidate, + digest: digest, + state: withChannel(emptyState(candidate), Channel{Scope: ScopeMinor, Tag: "1.2"}, TagState{ + Present: true, + Digest: other, + }), + wantErr: ErrChannelCorrupt, + }, + { + name: "minor channel outside its minor line", + version: candidate, + digest: digest, + state: withChannel(emptyState(candidate), Channel{Scope: ScopeMinor, Tag: "1.2"}, TagState{ + Present: true, + Digest: other, + HasVersion: true, + Version: otherMinor, + }), + wantErr: ErrChannelCorrupt, + }, + { + name: "major channel outside its major line", + version: candidate, + digest: digest, + state: withChannel(emptyState(candidate), Channel{Scope: ScopeMajor, Tag: "1"}, TagState{ + Present: true, + Digest: other, + HasVersion: true, + Version: otherMajor, + }), + wantErr: ErrChannelCorrupt, + }, + { + name: "latest may cross major lines", + version: candidate, + digest: digest, + state: withChannel(emptyState(candidate), Channel{Scope: ScopeLatest, Tag: "latest"}, TagState{ + Present: true, + Digest: other, + HasVersion: true, + Version: olderMajor, + }), + want: []Decision{ + {Tag: "1.2.3", Scope: ScopeExact, Action: ActionCreate}, + {Tag: "1.2", Scope: ScopeMinor, Action: ActionCreate}, + {Tag: "1", Scope: ScopeMajor, Action: ActionCreate}, + {Tag: "latest", Scope: ScopeLatest, Action: ActionCreate}, + }, + wantApply: []Tag{"1.2.3", "1.2", "1", "latest"}, + }, + { + name: "channel missing from state map", + version: candidate, + digest: digest, + state: ChannelState{ + Channels: map[Channel]TagState{ + {Scope: ScopeMinor, Tag: "1.2"}: {Present: false}, + {Scope: ScopeMajor, Tag: "1"}: {Present: false}, + }, + }, + wantErr: ErrStateIncomplete, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := PlanTags(test.version, test.digest, test.state) + if test.name == "empty digest" { + require.Error(t, err) + assert.Contains(t, err.Error(), "digest is empty") + return + } + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, test.version, got.Version) + assert.Equal(t, test.digest, got.Digest) + assert.Equal(t, test.want, got.Decisions) + assert.Equal(t, test.wantApply, got.Apply()) + }) + } +} + +func TestPlanTagsRejectsEmptyDigest(t *testing.T) { + t.Parallel() + + _, err := PlanTags(Version{Major: 1, Minor: 2, Patch: 3}, "", emptyState(Version{Major: 1, Minor: 2, Patch: 3})) + require.Error(t, err) + assert.Contains(t, err.Error(), "digest is empty") +} + +func TestTagPlanApplyAllocatesOnce(t *testing.T) { + t.Parallel() + + plan := TagPlan{ + Decisions: []Decision{ + {Tag: "1.2.3", Scope: ScopeExact, Action: ActionCreate}, + {Tag: "1.2", Scope: ScopeMinor, Action: ActionAccept}, + {Tag: "1", Scope: ScopeMajor, Action: ActionRetain}, + {Tag: "latest", Scope: ScopeLatest, Action: ActionCreate}, + }, + } + + assert.Equal(t, []Tag{"1.2.3", "latest"}, plan.Apply()) +} + +// mustDigest parses a digest or fails the test. +func mustDigest(t *testing.T, value string) Digest { + t.Helper() + + digest, err := ParseDigest(value) + require.NoError(t, err) + + return digest +} + +// emptyState is absent exact and channel tags for v. +func emptyState(v Version) ChannelState { + channels := make(map[Channel]TagState, channelCount) + for _, channel := range ChannelsFor(v) { + channels[channel] = TagState{} + } + + return ChannelState{Channels: channels} +} + +// withExact returns state with a replaced exact tag observation. +func withExact(state ChannelState, exact TagState) ChannelState { + state.Exact = exact + + return state +} + +// withChannel returns state with one replaced channel observation. +func withChannel(state ChannelState, channel Channel, observed TagState) ChannelState { + state.Channels[channel] = observed + + return state +} + +// annotatedState points every channel at version on digest. +func annotatedState(v Version, digest Digest, version Version) ChannelState { + state := emptyState(v) + for _, channel := range ChannelsFor(v) { + state.Channels[channel] = TagState{ + Present: true, + Digest: digest, + HasVersion: true, + Version: version, + } + } + + return state +} diff --git a/internal/rel/version.go b/internal/rel/version.go new file mode 100644 index 0000000..e3c2d50 --- /dev/null +++ b/internal/rel/version.go @@ -0,0 +1,148 @@ +package rel + +import ( + "cmp" + "fmt" + "strconv" + "strings" +) + +// versionComponents is the number of dotted parts in a stable triple. +const versionComponents = 3 + +// Version is a canonical stable MAJOR.MINOR.PATCH triple. +// +// The only constructor is [ParseVersion]. The zero value is 0.0.0, which is +// a valid version. +type Version struct { + // Major is the leftmost version component. + Major uint64 + // Minor is the middle version component. + Minor uint64 + // Patch is the rightmost version component. + Patch uint64 +} + +// ParseVersion constructs a [Version] from a stable MAJOR.MINOR.PATCH string. +// +// The grammar is exactly three decimal components with no leading zeros +// (except the value 0), no v prefix, no sign, no prerelease, and no build +// metadata. A component that does not fit in uint64 is rejected. Error text +// names the problem and echoes the input. +func ParseVersion(value string) (Version, error) { + if strings.TrimSpace(value) == "" { + return Version{}, fmt.Errorf("version %q is empty", value) + } + if strings.HasPrefix(value, "v") || strings.HasPrefix(value, "V") { + return Version{}, fmt.Errorf("version %q has a v prefix", value) + } + if err := rejectVersionDecorators(value); err != nil { + return Version{}, err + } + parts := strings.Split(value, ".") + if len(parts) != versionComponents { + return Version{}, fmt.Errorf( + "version %q has %d components, want %d", + value, + len(parts), + versionComponents, + ) + } + + major, err := parseVersionComponent(value, "major", parts[0]) + if err != nil { + return Version{}, err + } + minor, err := parseVersionComponent(value, "minor", parts[1]) + if err != nil { + return Version{}, err + } + patch, err := parseVersionComponent(value, "patch", parts[2]) + if err != nil { + return Version{}, err + } + + return Version{Major: major, Minor: minor, Patch: patch}, nil +} + +// Compare reports the order of v and other. +// +// It returns -1 if v is less, 0 if they are equal, and +1 if v is greater. +// Components are compared as major, then minor, then patch. +func (v Version) Compare(other Version) int { + if result := cmp.Compare(v.Major, other.Major); result != 0 { + return result + } + if result := cmp.Compare(v.Minor, other.Minor); result != 0 { + return result + } + + return cmp.Compare(v.Patch, other.Patch) +} + +// String returns the canonical MAJOR.MINOR.PATCH form. +func (v Version) String() string { + return strconv.FormatUint(v.Major, 10) + "." + + strconv.FormatUint(v.Minor, 10) + "." + + strconv.FormatUint(v.Patch, 10) +} + +// Tag returns the exact-version registry tag, which equals [Version.String]. +// +// A decimal triple always starts with a digit and contains only digits and +// dots, so the result is a valid OCI tag. +func (v Version) Tag() Tag { + return Tag(v.String()) +} + +// rejectVersionDecorators rejects prerelease and build-metadata suffixes. +// +// A leading + or - on a component is left for [parseVersionComponent] so +// signed numbers stay distinct from SemVer suffixes. +func rejectVersionDecorators(value string) error { + if plus := strings.IndexByte(value, '+'); plus >= 0 && !isComponentStart(value, plus) { + return fmt.Errorf("version %q has build metadata", value) + } + if hyphen := strings.IndexByte(value, '-'); hyphen >= 0 && !isComponentStart(value, hyphen) { + return fmt.Errorf("version %q has a prerelease suffix", value) + } + + return nil +} + +// isComponentStart reports whether index is the first character of a dotted +// component, including the start of the string. +func isComponentStart(value string, index int) bool { + return index == 0 || value[index-1] == '.' +} + +// parseVersionComponent parses one decimal component of a stable version. +func parseVersionComponent(input, name, part string) (uint64, error) { + if part == "" { + return 0, fmt.Errorf("version %q has an empty %s component", input, name) + } + if part[0] == '+' || part[0] == '-' { + return 0, fmt.Errorf("version %q has a signed %s component", input, name) + } + if len(part) > 1 && part[0] == '0' { + return 0, fmt.Errorf("version %q has a leading zero in the %s component", input, name) + } + if strings.Contains(part, "+") { + return 0, fmt.Errorf("version %q has build metadata", input) + } + if strings.Contains(part, "-") { + return 0, fmt.Errorf("version %q has a prerelease suffix", input) + } + for _, r := range part { + if r < '0' || r > '9' { + return 0, fmt.Errorf("version %q has a non-numeric %s component", input, name) + } + } + + value, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return 0, fmt.Errorf("version %q has a %s component that exceeds uint64", input, name) + } + + return value, nil +} diff --git a/internal/rel/version_test.go b/internal/rel/version_test.go new file mode 100644 index 0000000..78b2619 --- /dev/null +++ b/internal/rel/version_test.go @@ -0,0 +1,223 @@ +package rel + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want Version + wantErr string + }{ + { + name: "zero triple", + input: "0.0.0", + want: Version{}, + }, + { + name: "stable triple", + input: "1.2.3", + want: Version{Major: 1, Minor: 2, Patch: 3}, + }, + { + name: "multi-digit components", + input: "10.20.30", + want: Version{Major: 10, Minor: 20, Patch: 30}, + }, + { + name: "max uint64 components", + input: "18446744073709551615.18446744073709551615.18446744073709551615", + want: Version{ + Major: 18446744073709551615, + Minor: 18446744073709551615, + Patch: 18446744073709551615, + }, + }, + { + name: "leading zero in major", + input: "01.2.3", + wantErr: `version "01.2.3" has a leading zero in the major component`, + }, + { + name: "leading zero in minor", + input: "1.02.3", + wantErr: `version "1.02.3" has a leading zero in the minor component`, + }, + { + name: "leading zero in patch", + input: "1.2.03", + wantErr: `version "1.2.03" has a leading zero in the patch component`, + }, + { + name: "v prefix", + input: "v1.2.3", + wantErr: `version "v1.2.3" has a v prefix`, + }, + { + name: "uppercase V prefix", + input: "V1.2.3", + wantErr: `version "V1.2.3" has a v prefix`, + }, + { + name: "prerelease", + input: "1.2.3-rc.1", + wantErr: `version "1.2.3-rc.1" has a prerelease suffix`, + }, + { + name: "build metadata", + input: "1.2.3+build.1", + wantErr: `version "1.2.3+build.1" has build metadata`, + }, + { + name: "empty", + input: "", + wantErr: `version "" is empty`, + }, + { + name: "whitespace only", + input: " ", + wantErr: `version " " is empty`, + }, + { + name: "two components", + input: "1.2", + wantErr: `version "1.2" has 2 components, want 3`, + }, + { + name: "four components", + input: "1.2.3.4", + wantErr: `version "1.2.3.4" has 4 components, want 3`, + }, + { + name: "non-numeric major", + input: "a.2.3", + wantErr: `version "a.2.3" has a non-numeric major component`, + }, + { + name: "signed major", + input: "+1.2.3", + wantErr: `version "+1.2.3" has a signed major component`, + }, + { + name: "negative patch", + input: "1.2.-3", + wantErr: `version "1.2.-3" has a signed patch component`, + }, + { + name: "component larger than uint64", + input: "1.2.99999999999999999999", + wantErr: `version "1.2.99999999999999999999" has a patch component that exceeds uint64`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseVersion(test.input) + if test.wantErr != "" { + require.EqualError(t, err, test.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func TestVersionCompare(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + left Version + right Version + want int + }{ + { + name: "equal", + left: Version{Major: 1, Minor: 2, Patch: 3}, + right: Version{Major: 1, Minor: 2, Patch: 3}, + want: 0, + }, + { + name: "major less", + left: Version{Major: 1, Minor: 9, Patch: 9}, + right: Version{Major: 2, Minor: 0, Patch: 0}, + want: -1, + }, + { + name: "major greater", + left: Version{Major: 2, Minor: 0, Patch: 0}, + right: Version{Major: 1, Minor: 9, Patch: 9}, + want: 1, + }, + { + name: "minor less", + left: Version{Major: 1, Minor: 1, Patch: 9}, + right: Version{Major: 1, Minor: 2, Patch: 0}, + want: -1, + }, + { + name: "minor greater", + left: Version{Major: 1, Minor: 2, Patch: 0}, + right: Version{Major: 1, Minor: 1, Patch: 9}, + want: 1, + }, + { + name: "patch less", + left: Version{Major: 1, Minor: 2, Patch: 3}, + right: Version{Major: 1, Minor: 2, Patch: 4}, + want: -1, + }, + { + name: "patch greater", + left: Version{Major: 1, Minor: 2, Patch: 4}, + right: Version{Major: 1, Minor: 2, Patch: 3}, + want: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.want, test.left.Compare(test.right)) + }) + } +} + +func TestVersionStringAndTag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version Version + want string + }{ + {name: "zero", version: Version{}, want: "0.0.0"}, + {name: "stable", version: Version{Major: 1, Minor: 2, Patch: 3}, want: "1.2.3"}, + {name: "multi-digit", version: Version{Major: 10, Minor: 20, Patch: 30}, want: "10.20.30"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.want, test.version.String()) + assert.Equal(t, Tag(test.want), test.version.Tag()) + + parsed, err := ParseVersion(test.version.String()) + require.NoError(t, err) + assert.Equal(t, test.version, parsed) + }) + } +} diff --git a/internal/stage/puboci/doc.go b/internal/stage/puboci/doc.go new file mode 100644 index 0000000..7d51e8e --- /dev/null +++ b/internal/stage/puboci/doc.go @@ -0,0 +1,6 @@ +// Package puboci plans immutable exact tags and moving channel tags. +// +// [CollectState] reads current registry state through [StateReader]. +// [PlanTags] feeds that state to [rel.PlanTags]. The package performs no +// registry writes and does not retry transient failures. +package puboci diff --git a/internal/stage/puboci/tags.go b/internal/stage/puboci/tags.go new file mode 100644 index 0000000..2e76cd4 --- /dev/null +++ b/internal/stage/puboci/tags.go @@ -0,0 +1,209 @@ +package puboci + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "unicode" + + "github.com/meigma/release/internal/rel" +) + +const ( + // minImageParts is host plus at least one path element. + minImageParts = 2 + // schemeMarker separates a URL scheme from the rest of a reference. + schemeMarker = "://" +) + +// Sentinel errors classified for registry reads. +var ( + // ErrTagAbsent reports that a tag does not resolve in the registry. + ErrTagAbsent = errors.New("registry tag not found") + // ErrRetryable reports a transient registry failure. [CollectState] does + // not retry; the adapter classifies the failure and the caller decides + // whether to try again. + ErrRetryable = errors.New("retryable registry error") + // ErrCorruptState reports that a present tag's version annotation is + // missing or not a stable version. + ErrCorruptState = errors.New("corrupt registry state") +) + +// Image is a lowercase untagged registry repository name. +// +// The only constructor is [ParseImage]. The zero value is invalid. +type Image string + +// ParseImage constructs an [Image] from a lowercase registry reference. +// +// The grammar is host/path[/path...] with no scheme, tag, or digest. The host +// is the text before the first slash and may include a port. At least one path +// element is required. Empty input, uppercase letters, a scheme, a :tag suffix +// on the last element, an @digest, leading or trailing slashes, empty path +// elements, and spaces are rejected. +func ParseImage(value string) (Image, error) { + if value == "" { + return "", fmt.Errorf("image %q is empty", value) + } + if strings.ContainsFunc(value, unicode.IsSpace) { + return "", fmt.Errorf("image %q contains a space", value) + } + if value != strings.ToLower(value) { + return "", fmt.Errorf("image %q contains an uppercase letter", value) + } + if strings.Contains(value, schemeMarker) { + return "", fmt.Errorf("image %q has a scheme", value) + } + if strings.Contains(value, "@") { + return "", fmt.Errorf("image %q has a digest", value) + } + if strings.HasPrefix(value, "/") { + return "", fmt.Errorf("image %q has a leading slash", value) + } + if strings.HasSuffix(value, "/") { + return "", fmt.Errorf("image %q has a trailing slash", value) + } + + parts := strings.Split(value, "/") + if len(parts) < minImageParts { + return "", fmt.Errorf("image %q has no path", value) + } + if slices.Contains(parts, "") { + return "", fmt.Errorf("image %q has an empty path element", value) + } + if strings.Contains(parts[len(parts)-1], ":") { + return "", fmt.Errorf("image %q has a tag", value) + } + + return Image(value), nil +} + +// String returns the untagged repository name. +func (i Image) String() string { + return string(i) +} + +// Reference binds i to tag. +func (i Image) Reference(tag rel.Tag) Reference { + return Reference{Image: i, Tag: tag} +} + +// Reference is an image together with one tag. +type Reference struct { + // Image is the untagged repository name. + Image Image + // Tag is the exact or channel tag. + Tag rel.Tag +} + +// String returns image:tag. +func (r Reference) String() string { + return r.Image.String() + ":" + r.Tag.String() +} + +// StateReader reads current registry tag state. +// +// Implementations classify not-found as [ErrTagAbsent], transient failures as +// [ErrRetryable], and unusable version annotations as [ErrCorruptState]. +type StateReader interface { + // Resolve returns the digest currently pointed at by ref. + // + // An error wrapping [ErrTagAbsent] means the tag is not present. + Resolve(ctx context.Context, ref Reference) (rel.Digest, error) + + // Version returns the org.opencontainers.image.version annotation at ref. + // + // Callers invoke Version only when ref is present and resolves to a + // digest other than the candidate. An error wrapping [ErrCorruptState] + // means the annotation is missing or not a stable version. + Version(ctx context.Context, ref Reference) (rel.Version, error) +} + +// CollectState reads the exact tag and every moving channel for version. +// +// A nil context or reader is rejected. A cancelled context fails before the +// port is called. [ErrTagAbsent] from [StateReader.Resolve] is treated as an +// absent tag. Any other Resolve or Version error is wrapped with the +// reference and returned. Version is called only when a channel tag is present +// and its digest differs from digest, so a present differing tag never returns +// with HasVersion false. Transient failures classified as [ErrRetryable] are +// not retried. CollectState performs no registry writes. +func CollectState( + ctx context.Context, + reader StateReader, + image Image, + version rel.Version, + digest rel.Digest, +) (rel.ChannelState, error) { + if ctx == nil { + return rel.ChannelState{}, errors.New("context is nil") + } + if reader == nil { + return rel.ChannelState{}, errors.New("state reader is nil") + } + if err := ctx.Err(); err != nil { + return rel.ChannelState{}, fmt.Errorf("collect state: %w", err) + } + + exact, err := resolveState(ctx, reader, image.Reference(version.Tag())) + if err != nil { + return rel.ChannelState{}, err + } + + channels := rel.ChannelsFor(version) + observed := make(map[rel.Channel]rel.TagState, len(channels)) + for _, channel := range channels { + ref := image.Reference(channel.Tag) + state, err := resolveState(ctx, reader, ref) + if err != nil { + return rel.ChannelState{}, err + } + if state.Present && state.Digest != digest { + annotated, err := reader.Version(ctx, ref) + if err != nil { + return rel.ChannelState{}, fmt.Errorf("version %s: %w", ref, err) + } + state.HasVersion = true + state.Version = annotated + } + observed[channel] = state + } + + return rel.ChannelState{Exact: exact, Channels: observed}, nil +} + +// PlanTags collects registry state and decides which tags the candidate may apply. +// +// It is [CollectState] followed by [rel.PlanTags]. A planner failure is +// returned unchanged so callers can inspect the [rel] sentinels. PlanTags +// performs no registry writes and does not retry transient failures. +func PlanTags( + ctx context.Context, + reader StateReader, + image Image, + version rel.Version, + digest rel.Digest, +) (rel.TagPlan, error) { + current, err := CollectState(ctx, reader, image, version, digest) + if err != nil { + return rel.TagPlan{}, err + } + + return rel.PlanTags(version, digest, current) +} + +// resolveState reads one tag and treats [ErrTagAbsent] as not present. +func resolveState(ctx context.Context, reader StateReader, ref Reference) (rel.TagState, error) { + resolved, err := reader.Resolve(ctx, ref) + if err != nil { + if errors.Is(err, ErrTagAbsent) { + return rel.TagState{}, nil + } + + return rel.TagState{}, fmt.Errorf("resolve %s: %w", ref, err) + } + + return rel.TagState{Present: true, Digest: resolved}, nil +} diff --git a/internal/stage/puboci/tags_test.go b/internal/stage/puboci/tags_test.go new file mode 100644 index 0000000..6809423 --- /dev/null +++ b/internal/stage/puboci/tags_test.go @@ -0,0 +1,412 @@ +package puboci_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + regmocks "github.com/meigma/release/internal/adapter/reg/mocks" + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/puboci" +) + +const ( + validDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + otherDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +func TestParseImage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr string + }{ + {name: "ghcr repository", input: "ghcr.io/owner/repo", want: "ghcr.io/owner/repo"}, + {name: "localhost with port", input: "localhost:5000/team/app", want: "localhost:5000/team/app"}, + {name: "nested path", input: "ghcr.io/owner/repo/app", want: "ghcr.io/owner/repo/app"}, + {name: "empty", input: "", wantErr: `image "" is empty`}, + {name: "uppercase", input: "ghcr.io/Owner/repo", wantErr: "contains an uppercase letter"}, + {name: "scheme", input: "https://ghcr.io/owner/repo", wantErr: "has a scheme"}, + {name: "tag suffix", input: "ghcr.io/owner/repo:1.2.3", wantErr: "has a tag"}, + { + name: "digest", + input: "ghcr.io/owner/repo@sha256:" + strings.Repeat("a", 64), + wantErr: "has a digest", + }, + {name: "leading slash", input: "/ghcr.io/owner/repo", wantErr: "has a leading slash"}, + {name: "trailing slash", input: "ghcr.io/owner/repo/", wantErr: "has a trailing slash"}, + {name: "space", input: "ghcr.io/owner/repo extra", wantErr: "contains a space"}, + {name: "host only", input: "ghcr.io", wantErr: "has no path"}, + {name: "empty path element", input: "ghcr.io//repo", wantErr: "has an empty path element"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := puboci.ParseImage(test.input) + if test.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got.String()) + }) + } +} + +func TestReferenceString(t *testing.T) { + t.Parallel() + + image := mustImage(t, "ghcr.io/owner/repo") + ref := image.Reference(rel.Tag("1.2.3")) + assert.Equal(t, "ghcr.io/owner/repo:1.2.3", ref.String()) +} + +func TestCollectState(t *testing.T) { + t.Parallel() + + fixture := newPlanFixture(t) + older := rel.Version{Major: 1, Minor: 2, Patch: 2} + + tests := []struct { + name string + setup func(reader *regmocks.MockStateReader) + want rel.ChannelState + wantErr string + wantIs error + }{ + { + name: "every tag absent", + setup: func(reader *regmocks.MockStateReader) { + expectAbsent(reader, fixture.exact) + expectAbsent(reader, fixture.minor) + expectAbsent(reader, fixture.major) + expectAbsent(reader, fixture.latest) + }, + want: emptyState(fixture.version), + }, + { + name: "exact present at the candidate digest", + setup: func(reader *regmocks.MockStateReader) { + expectDigest(reader, fixture.exact, fixture.digest) + expectAbsent(reader, fixture.minor) + expectAbsent(reader, fixture.major) + expectAbsent(reader, fixture.latest) + }, + want: withExact(emptyState(fixture.version), rel.TagState{ + Present: true, + Digest: fixture.digest, + }), + }, + { + name: "exact present at another digest", + setup: func(reader *regmocks.MockStateReader) { + expectDigest(reader, fixture.exact, fixture.other) + expectAbsent(reader, fixture.minor) + expectAbsent(reader, fixture.major) + expectAbsent(reader, fixture.latest) + }, + want: withExact(emptyState(fixture.version), rel.TagState{ + Present: true, + Digest: fixture.other, + }), + }, + { + name: "channel present at the candidate digest", + setup: func(reader *regmocks.MockStateReader) { + expectAbsent(reader, fixture.exact) + expectDigest(reader, fixture.minor, fixture.digest) + expectAbsent(reader, fixture.major) + expectAbsent(reader, fixture.latest) + }, + want: withChannel(emptyState(fixture.version), fixture.minorChannel, rel.TagState{ + Present: true, + Digest: fixture.digest, + }), + }, + { + name: "channel present at another digest reads the annotation once", + setup: func(reader *regmocks.MockStateReader) { + expectAbsent(reader, fixture.exact) + expectDigest(reader, fixture.minor, fixture.other) + reader.EXPECT(). + Version(mock.Anything, fixture.minor). + Return(older, nil). + Once() + expectAbsent(reader, fixture.major) + expectAbsent(reader, fixture.latest) + }, + want: withChannel(emptyState(fixture.version), fixture.minorChannel, rel.TagState{ + Present: true, + Digest: fixture.other, + HasVersion: true, + Version: older, + }), + }, + { + name: "wrapped tag-absent is treated as absent", + setup: func(reader *regmocks.MockStateReader) { + reader.EXPECT(). + Resolve(mock.Anything, fixture.exact). + Return(rel.Digest(""), fmtWrap(puboci.ErrTagAbsent)). + Once() + expectAbsent(reader, fixture.minor) + expectAbsent(reader, fixture.major) + expectAbsent(reader, fixture.latest) + }, + want: emptyState(fixture.version), + }, + { + name: "retryable resolve is propagated", + setup: func(reader *regmocks.MockStateReader) { + reader.EXPECT(). + Resolve(mock.Anything, fixture.exact). + Return(rel.Digest(""), puboci.ErrRetryable). + Once() + }, + wantErr: fixture.exact.String(), + wantIs: puboci.ErrRetryable, + }, + { + name: "corrupt version is propagated", + setup: func(reader *regmocks.MockStateReader) { + expectAbsent(reader, fixture.exact) + expectDigest(reader, fixture.minor, fixture.other) + reader.EXPECT(). + Version(mock.Anything, fixture.minor). + Return(rel.Version{}, puboci.ErrCorruptState). + Once() + }, + wantErr: fixture.minor.String(), + wantIs: puboci.ErrCorruptState, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + reader := regmocks.NewMockStateReader(t) + test.setup(reader) + + got, err := puboci.CollectState( + context.Background(), + reader, + fixture.image, + fixture.version, + fixture.digest, + ) + if test.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + require.ErrorIs(t, err, test.wantIs) + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func TestCollectStateRejectsNilReader(t *testing.T) { + t.Parallel() + + fixture := newPlanFixture(t) + _, err := puboci.CollectState( + context.Background(), + nil, + fixture.image, + fixture.version, + fixture.digest, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "state reader is nil") +} + +func TestCollectStateRejectsNilContext(t *testing.T) { + t.Parallel() + + fixture := newPlanFixture(t) + reader := regmocks.NewMockStateReader(t) + var ctx context.Context + _, err := puboci.CollectState(ctx, reader, fixture.image, fixture.version, fixture.digest) + require.Error(t, err) + assert.Contains(t, err.Error(), "context is nil") +} + +func TestCollectStateCancelledContext(t *testing.T) { + t.Parallel() + + fixture := newPlanFixture(t) + reader := regmocks.NewMockStateReader(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := puboci.CollectState(ctx, reader, fixture.image, fixture.version, fixture.digest) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + assert.Contains(t, err.Error(), context.Canceled.Error()) +} + +func TestPlanTagsNewRelease(t *testing.T) { + t.Parallel() + + fixture := newPlanFixture(t) + reader := regmocks.NewMockStateReader(t) + expectAbsent(reader, fixture.exact) + expectAbsent(reader, fixture.minor) + expectAbsent(reader, fixture.major) + expectAbsent(reader, fixture.latest) + + got, err := puboci.PlanTags( + context.Background(), + reader, + fixture.image, + fixture.version, + fixture.digest, + ) + require.NoError(t, err) + assert.Equal(t, fixture.version, got.Version) + assert.Equal(t, fixture.digest, got.Digest) + assert.Equal(t, []rel.Decision{ + {Tag: "1.2.3", Scope: rel.ScopeExact, Action: rel.ActionCreate}, + {Tag: "1.2", Scope: rel.ScopeMinor, Action: rel.ActionCreate}, + {Tag: "1", Scope: rel.ScopeMajor, Action: rel.ActionCreate}, + {Tag: "latest", Scope: rel.ScopeLatest, Action: rel.ActionCreate}, + }, got.Decisions) + assert.Equal(t, []rel.Tag{"1.2.3", "1.2", "1", "latest"}, got.Apply()) +} + +func TestPlanTagsImmutableExactTag(t *testing.T) { + t.Parallel() + + fixture := newPlanFixture(t) + reader := regmocks.NewMockStateReader(t) + expectDigest(reader, fixture.exact, fixture.other) + expectAbsent(reader, fixture.minor) + expectAbsent(reader, fixture.major) + expectAbsent(reader, fixture.latest) + + _, err := puboci.PlanTags( + context.Background(), + reader, + fixture.image, + fixture.version, + fixture.digest, + ) + require.ErrorIs(t, err, rel.ErrImmutableTag) +} + +// planFixture holds a candidate release and the references CollectState reads. +type planFixture struct { + // image is the repository whose tags are planned. + image puboci.Image + // version is the candidate release version. + version rel.Version + // digest is the candidate image digest. + digest rel.Digest + // other is a different digest used for conflict cases. + other rel.Digest + // exact is the exact-version tag reference. + exact puboci.Reference + // minor is the minor channel tag reference. + minor puboci.Reference + // major is the major channel tag reference. + major puboci.Reference + // latest is the latest channel tag reference. + latest puboci.Reference + // minorChannel is the minor channel key in ChannelState.Channels. + minorChannel rel.Channel +} + +// newPlanFixture constructs a 1.2.3 candidate against ghcr.io/owner/repo. +func newPlanFixture(t *testing.T) planFixture { + t.Helper() + + image := mustImage(t, "ghcr.io/owner/repo") + version := rel.Version{Major: 1, Minor: 2, Patch: 3} + channels := rel.ChannelsFor(version) + + return planFixture{ + image: image, + version: version, + digest: mustDigest(t, validDigest), + other: mustDigest(t, otherDigest), + exact: image.Reference(version.Tag()), + minor: image.Reference(channels[0].Tag), + major: image.Reference(channels[1].Tag), + latest: image.Reference(channels[2].Tag), + minorChannel: channels[0], + } +} + +// mustImage parses an image name or fails the test. +func mustImage(t *testing.T, value string) puboci.Image { + t.Helper() + + image, err := puboci.ParseImage(value) + require.NoError(t, err) + + return image +} + +// mustDigest parses a digest or fails the test. +func mustDigest(t *testing.T, value string) rel.Digest { + t.Helper() + + digest, err := rel.ParseDigest(value) + require.NoError(t, err) + + return digest +} + +// expectAbsent expects Resolve(ref) to report [ErrTagAbsent] once. +func expectAbsent(reader *regmocks.MockStateReader, ref puboci.Reference) { + reader.EXPECT().Resolve(mock.Anything, ref).Return(rel.Digest(""), puboci.ErrTagAbsent).Once() +} + +// expectDigest expects Resolve(ref) to return digest once. +func expectDigest(reader *regmocks.MockStateReader, ref puboci.Reference, digest rel.Digest) { + reader.EXPECT().Resolve(mock.Anything, ref).Return(digest, nil).Once() +} + +// emptyState is absent exact and channel tags for version. +func emptyState(version rel.Version) rel.ChannelState { + channels := make(map[rel.Channel]rel.TagState, len(rel.ChannelsFor(version))) + for _, channel := range rel.ChannelsFor(version) { + channels[channel] = rel.TagState{} + } + + return rel.ChannelState{Channels: channels} +} + +// withExact returns state with a replaced exact tag observation. +func withExact(state rel.ChannelState, exact rel.TagState) rel.ChannelState { + state.Exact = exact + + return state +} + +// withChannel returns state with one replaced channel observation. +func withChannel(state rel.ChannelState, channel rel.Channel, observed rel.TagState) rel.ChannelState { + state.Channels[channel] = observed + + return state +} + +// fmtWrap wraps err so [errors.Is] still matches the sentinel. +func fmtWrap(err error) error { + return errors.Join(errors.New("missing"), err) +} From 3432ca801593ee5354583c168f03a827aa52fd3e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 18 Aug 2026 22:46:46 -0700 Subject: [PATCH 2/2] fix(oci): harden registry reads and align tag docs Round-1 review and conformance fixes: default oras retry transport for production reads, transport failures classified retryable, registry credentials held only in the auth closure, CLI Godoc and reference docs corrected, and added coverage for JSON configuration failures. --- docs/reference/release-cli-contract.md | 19 +++--- internal/adapter/reg/client.go | 80 +++++++++++++------------- internal/adapter/reg/state.go | 34 ++++++++++- internal/adapter/reg/state_test.go | 70 ++++++++++++++++++++-- internal/cli/doc.go | 4 +- internal/cli/result.go | 4 +- internal/cli/tags_test.go | 73 +++++++++++++++++++++++ internal/rel/tag_test.go | 7 +-- 8 files changed, 224 insertions(+), 67 deletions(-) diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md index a13be57..68b10f1 100644 --- a/docs/reference/release-cli-contract.md +++ b/docs/reference/release-cli-contract.md @@ -101,12 +101,13 @@ The `verify handoff --json` result contains this object: | `artifact.run_id` | number | Workflow run ID associated with the artifact. | | `artifact.expires_at` | string | Artifact expiration time in RFC 3339 format, or an empty string if GitHub omitted it. | -After successful parsing, a command failure under `--json` sets `ok` to `false` -and gives `result` one string field named `error`. The command also returns its -nonzero exit code. If parsing itself fails because of an unknown command or -flag, an invalid flag value, or the wrong number of arguments, no envelope is -written; the usage error goes to stderr and the process exits with code -`2`. +After command-line parsing and dispatch succeed, a command or configuration +failure under `--json` sets `ok` to `false` and gives `result` one string field +named `error`. The command also returns its nonzero exit code. Configuration +failures return code `2` and still emit exactly one envelope. Only command-line +parse or dispatch failures skip the envelope. These include an unknown command +or flag, an invalid flag value, or the wrong number of arguments. The usage +error goes to stderr and the process exits with code `2`. Without `--json`, a successful `plan tags`, `stage`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli (, protocol )` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for all commands. @@ -129,7 +130,7 @@ No other exit code is defined; in particular, code `3` has no meaning. An exit c | Image | `--image` | `RELEASE_IMAGE` | `ghcr.io//`, lowercased from `GITHUB_REPOSITORY`. | | Version | `--version` | `RELEASE_VERSION` | `GITHUB_REF_NAME` with one optional leading `v` stripped. | | Digest | `--digest` | `RELEASE_DIGEST` | None. A digest is required. | -| JSON output | `--json` | None | Disabled. | +| JSON output | `--json` | `RELEASE_JSON` | Disabled. | An explicitly set flag takes precedence over its environment variable. The derived default applies only when the corresponding flag and release environment variable are absent. The image must have the lowercase form `host/path[/path...]` without a tag or digest. The digest must have the `sha256:` prefix followed by 64 hexadecimal digits. @@ -142,7 +143,7 @@ The command resolves registry credentials in this order: If neither token is present, the command reads the registry anonymously. Anonymous reads work only for public packages. -Missing or invalid configuration exits with code `2`. A planning or registry failure exits with code `1`. +Missing or invalid configuration exits with code `2`. Under `--json`, this failure still writes exactly one envelope with `ok` set to `false`. A planning or registry failure exits with code `1`. `plan tags` performs registry reads only. It never writes a tag, blob, or manifest. The reusable publisher workflow still owns tag application in this release. Its existing `actions/github-script` tag planner remains authoritative for publication. The workflow does not call `plan tags` in this release. The command supports planning and inspection only. @@ -174,6 +175,8 @@ The command then evaluates channels in this order: An absent channel gets a `create` decision. A channel that already resolves to the candidate digest gets an `accept` decision. Otherwise, the command reads the current manifest's `org.opencontainers.image.version` annotation. A missing or invalid stable-version annotation fails planning. A minor or major channel outside its required release line also fails planning. +Failure ordering differs from the publisher workflow. The workflow checks the exact tag before it resolves any channel. The CLI collects the exact tag and all three channels before it decides the plan. If an immutable-tag conflict and a corrupt channel exist together, the CLI may report the channel failure instead of the immutable-tag conflict. Both planners refuse the plan, and the CLI exits with code `1`. Only the failure reported first can differ. + For a valid channel annotation on a different digest, the command compares the candidate version with the annotated version: | Comparison | Decision | diff --git a/internal/adapter/reg/client.go b/internal/adapter/reg/client.go index 5e443dd..5b3f7b3 100644 --- a/internal/adapter/reg/client.go +++ b/internal/adapter/reg/client.go @@ -1,11 +1,13 @@ package reg import ( + "context" "fmt" "net/http" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/retry" "github.com/meigma/release/internal/rel" "github.com/meigma/release/internal/stage/puboci" @@ -34,50 +36,59 @@ type Options struct { // local registry. PlainHTTP bool - // HTTPClient is the optional transport. Nil selects a default client. + // HTTPClient is the optional transport. Nil selects + // [retry.DefaultClient]. An injected client is used as-is so tests stay + // deterministic. HTTPClient *http.Client } // Client reads tag state from a GHCR-compatible registry. // // It implements [puboci.StateReader]. It never pushes, tags, or deletes. +// Credential material is captured inside the auth client's closure and is +// not stored on this value. type Client struct { - // auth is the shared oras auth client. Credential is applied per - // request so the registry host is known and token text is not stored - // on this value. + // auth is the shared oras auth client. auth *auth.Client - // options is the constructor configuration, including the redacted - // secret used to build per-request credentials. - options Options + // plainHTTP forces HTTP instead of HTTPS. + plainHTTP bool + + // authenticated reports whether New captured credentials. + authenticated bool } // New constructs a [Client] from options. // -// Token text stays inside [rel.Secret] until a request is built. The -// returned client is safe to format: password text is never a plain field. +// A nil HTTPClient selects [retry.DefaultClient]. When credentials are +// present, [rel.Secret.Reveal] is called once and the token is captured +// only inside the auth credential closure. func New(options Options) *Client { - return &Client{ - auth: &auth.Client{ - Client: options.HTTPClient, - Cache: auth.NewCache(), - }, - options: options, + httpClient := options.HTTPClient + if httpClient == nil { + httpClient = retry.DefaultClient } -} -// String reports the client without credential material. -func (c *Client) String() string { - if c == nil { - return "" + authClient := &auth.Client{ + Client: httpClient, + Cache: auth.NewCache(), + } + authenticated := options.Credentials.Username != "" || !options.Credentials.Password.IsEmpty() + if authenticated { + cred := auth.Credential{ + Username: options.Credentials.Username, + Password: options.Credentials.Password.Reveal(), + } + authClient.Credential = func(context.Context, string) (auth.Credential, error) { + return cred, nil + } } - return fmt.Sprintf("reg.Client{authenticated:%t plainHTTP:%t}", c.hasCredentials(), c.options.PlainHTTP) -} - -// GoString reports the client without credential material. -func (c *Client) GoString() string { - return c.String() + return &Client{ + auth: authClient, + plainHTTP: options.PlainHTTP, + authenticated: authenticated, + } } // repository builds a remote repository client for ref. @@ -87,21 +98,8 @@ func (c *Client) repository(ref puboci.Reference) (*remote.Repository, error) { return nil, fmt.Errorf("parse repository: %w", err) } - authClient := *c.auth - if c.hasCredentials() { - authClient.Credential = auth.StaticCredential(repo.Reference.Host(), auth.Credential{ - Username: c.options.Credentials.Username, - Password: c.options.Credentials.Password.Reveal(), - }) - } - - repo.Client = &authClient - repo.PlainHTTP = c.options.PlainHTTP + repo.Client = c.auth + repo.PlainHTTP = c.plainHTTP return repo, nil } - -// hasCredentials reports whether options include a username or password. -func (c *Client) hasCredentials() bool { - return c.options.Credentials.Username != "" || !c.options.Credentials.Password.IsEmpty() -} diff --git a/internal/adapter/reg/state.go b/internal/adapter/reg/state.go index b1be57c..08b3d58 100644 --- a/internal/adapter/reg/state.go +++ b/internal/adapter/reg/state.go @@ -6,7 +6,10 @@ import ( "errors" "fmt" "io" + "net" "net/http" + "net/url" + "syscall" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/errdef" @@ -103,7 +106,21 @@ func classify(err error) error { return fmt.Errorf("%w: request deadline exceeded", context.DeadlineExceeded) } if errors.Is(err, errdef.ErrNotFound) { - return fmt.Errorf("%w", puboci.ErrTagAbsent) + return puboci.ErrTagAbsent + } + + var urlErr *url.Error + if errors.As(err, &urlErr) { + cause := error(urlErr) + if urlErr.Err != nil { + cause = urlErr.Err + } + + return fmt.Errorf("%w: %s", puboci.ErrRetryable, transportReason(cause)) + } + var netErr net.Error + if errors.As(err, &netErr) { + return fmt.Errorf("%w: %s", puboci.ErrRetryable, transportReason(netErr)) } var respErr *errcode.ErrorResponse @@ -113,7 +130,7 @@ func classify(err error) error { switch code := respErr.StatusCode; { case code == http.StatusNotFound: - return fmt.Errorf("%w", puboci.ErrTagAbsent) + return puboci.ErrTagAbsent case code == http.StatusUnauthorized || code == http.StatusForbidden: return fmt.Errorf("registry authentication failed: status %d", code) case code == http.StatusTooManyRequests || code >= http.StatusInternalServerError: @@ -123,6 +140,19 @@ func classify(err error) error { } } +// transportReason returns a short, URL-free description of a transport failure. +func transportReason(err error) string { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "i/o timeout" + } + if errors.Is(err, syscall.ECONNREFUSED) { + return "connection refused" + } + + return "transport error" +} + // decodeVersion reads an OCI manifest's version annotation from body. func decodeVersion(body io.Reader) (rel.Version, error) { var payload annotationFile diff --git a/internal/adapter/reg/state_test.go b/internal/adapter/reg/state_test.go index ad3d75c..32c32e0 100644 --- a/internal/adapter/reg/state_test.go +++ b/internal/adapter/reg/state_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/http/httptest" "net/url" @@ -18,6 +19,7 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/registry/remote/retry" "github.com/meigma/release/internal/rel" "github.com/meigma/release/internal/stage/puboci" @@ -41,7 +43,7 @@ func TestResolveReturnsManifestDigest(t *testing.T) { server := newRegistryServer(t) body := indexJSON(t, map[string]string{ocispec.AnnotationVersion: testVersion}) - putManifest(t, server, testRepo, testTag, ocispec.MediaTypeImageIndex, body) + putManifest(t, server, ocispec.MediaTypeImageIndex, body) got, err := newPlainClient(server).Resolve(context.Background(), mustRef(t, server)) require.NoError(t, err) @@ -63,7 +65,7 @@ func TestVersionReadsAnnotation(t *testing.T) { server := newRegistryServer(t) body := indexJSON(t, map[string]string{ocispec.AnnotationVersion: testVersion}) - putManifest(t, server, testRepo, testTag, ocispec.MediaTypeImageIndex, body) + putManifest(t, server, ocispec.MediaTypeImageIndex, body) got, err := newPlainClient(server).Version(context.Background(), mustRef(t, server)) require.NoError(t, err) @@ -115,7 +117,7 @@ func TestVersionWrapsCorruptManifests(t *testing.T) { t.Parallel() server := newRegistryServer(t) - putManifest(t, server, testRepo, testTag, test.contentType, test.body) + putManifest(t, server, test.contentType, test.body) _, err := newPlainClient(server).Version(context.Background(), mustRef(t, server)) require.Error(t, err) @@ -215,6 +217,62 @@ func TestClientFormatOmitsToken(t *testing.T) { }}) assert.NotContains(t, fmt.Sprintf("%v", client), testToken) assert.NotContains(t, fmt.Sprintf("%+v", client), testToken) + assert.NotContains(t, fmt.Sprintf("%+v", *client), testToken) + assert.NotContains(t, fmt.Sprintf("%#v", client), testToken) +} + +func TestResolveRetriesTransientStatus(t *testing.T) { + t.Parallel() + + var hits atomic.Int32 + backend := registry.New() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPut && hits.Add(1) == 1 { + writer.WriteHeader(http.StatusServiceUnavailable) + + return + } + backend.ServeHTTP(writer, request) + })) + t.Cleanup(server.Close) + + body := indexJSON(t, map[string]string{ocispec.AnnotationVersion: testVersion}) + putManifest(t, server, ocispec.MediaTypeImageIndex, body) + + client := New(Options{ + PlainHTTP: true, + HTTPClient: &http.Client{ + Transport: retry.NewTransport(server.Client().Transport), + }, + }) + got, err := client.Resolve(context.Background(), mustRef(t, server)) + require.NoError(t, err) + assert.Equal(t, digestOf(body), got.String()) + assert.GreaterOrEqual(t, hits.Load(), int32(2)) +} + +func TestResolveUnreachableWrapsErrRetryable(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + address := listener.Addr().String() + require.NoError(t, listener.Close()) + + serverURL := "http://" + address + parsed, err := url.Parse(serverURL) + require.NoError(t, err) + image, err := puboci.ParseImage(parsed.Host + "/" + testRepo) + require.NoError(t, err) + tag, err := rel.ParseTag(testTag) + require.NoError(t, err) + + client := New(Options{PlainHTTP: true}) + _, resolveErr := client.Resolve(context.Background(), image.Reference(tag)) + require.Error(t, resolveErr) + require.ErrorIs(t, resolveErr, puboci.ErrRetryable) + assert.NotContains(t, resolveErr.Error(), serverURL) + assert.NotContains(t, resolveErr.Error(), address) } // newRegistryServer starts an in-process OCI registry. @@ -235,13 +293,13 @@ func newPlainClient(server *httptest.Server) *Client { }) } -// putManifest writes body as a tagged manifest on the fake registry. -func putManifest(t *testing.T, server *httptest.Server, repo, tag, contentType string, body []byte) { +// putManifest writes body as the testTag manifest for testRepo on the fake registry. +func putManifest(t *testing.T, server *httptest.Server, contentType string, body []byte) { t.Helper() req, err := http.NewRequest( http.MethodPut, - server.URL+"/v2/"+repo+"/manifests/"+tag, + server.URL+"/v2/"+testRepo+"/manifests/"+testTag, bytes.NewReader(body), ) require.NoError(t, err) diff --git a/internal/cli/doc.go b/internal/cli/doc.go index aa11783..64560f1 100644 --- a/internal/cli/doc.go +++ b/internal/cli/doc.go @@ -4,6 +4,6 @@ // [LookupEnv] seam. The tree exposes stage, plan tags, verify handoff, and // version. Flags override RELEASE_* environment variables via // [cobra.Flag.Changed]; there is no config file. ExitCode maps errors onto -// the process contract: 0 success, 1 verification failure, 2 usage or -// configuration error. +// the process contract: 0 success, 1 a release-contract, verification, or +// command failure, 2 usage or configuration error. package cli diff --git a/internal/cli/result.go b/internal/cli/result.go index 6923ec4..2420d41 100644 --- a/internal/cli/result.go +++ b/internal/cli/result.go @@ -31,8 +31,8 @@ var ErrUsage = errors.New("usage") // Envelope is the single JSON document emitted under --json. // -// Schema is always [Schema]. Command is the verb path ("stage", "version", -// or "verify handoff"). OK is true only on success. Result is +// Schema is always [Schema]. Command is the verb path ("stage", "plan tags", +// "version", or "verify handoff"). OK is true only on success. Result is // command-specific and must not be nil in a written document. A zero // Envelope is invalid and is never encoded. type Envelope struct { diff --git a/internal/cli/tags_test.go b/internal/cli/tags_test.go index 672c007..f569b83 100644 --- a/internal/cli/tags_test.go +++ b/internal/cli/tags_test.go @@ -101,6 +101,79 @@ func TestPlanTagsMalformedValuesAreUsage(t *testing.T) { } } +func TestPlanTagsJSONConfigFailure(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want string + }{ + { + name: "malformed digest", + args: []string{ + "plan", + "tags", + "--json", + "--image", + tagsImage, + "--version", + "1.2.3", + "--digest", + "not-a-digest", + }, + want: "digest", + }, + { + name: "malformed version", + args: []string{ + "plan", + "tags", + "--json", + "--image", + tagsImage, + "--version", + "v1.2.3", + "--digest", + tagsDigest, + }, + want: "v prefix", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + called := false + stdout, err := executeTagsFactory( + t, + nil, + tt.args, + func(cli.RegistryCredentials) (puboci.StateReader, error) { + called = true + return unusedReader(t), nil + }, + ) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.False(t, called) + assert.Equal(t, 1, countJSONDocuments(stdout)) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, "plan tags", envelope.Command) + assert.False(t, envelope.OK) + + raw, marshalErr := json.Marshal(envelope.Result) + require.NoError(t, marshalErr) + var result cli.ErrorResult + require.NoError(t, json.Unmarshal(raw, &result)) + assert.Contains(t, result.Error, tt.want) + }) + } +} + func TestPlanTagsDerivedDefaults(t *testing.T) { t.Parallel() diff --git a/internal/rel/tag_test.go b/internal/rel/tag_test.go index e3122b8..d8cc68f 100644 --- a/internal/rel/tag_test.go +++ b/internal/rel/tag_test.go @@ -282,11 +282,6 @@ func TestPlanTags(t *testing.T) { t.Parallel() got, err := PlanTags(test.version, test.digest, test.state) - if test.name == "empty digest" { - require.Error(t, err) - assert.Contains(t, err.Error(), "digest is empty") - return - } if test.wantErr != nil { require.ErrorIs(t, err, test.wantErr) return @@ -309,7 +304,7 @@ func TestPlanTagsRejectsEmptyDigest(t *testing.T) { assert.Contains(t, err.Error(), "digest is empty") } -func TestTagPlanApplyAllocatesOnce(t *testing.T) { +func TestTagPlanApplySelectsCreateDecisions(t *testing.T) { t.Parallel() plan := TagPlan{