feat(oci): add --oci-name-prefix for registries that require a path prefix
What this does
Adds --oci-name-prefix, which prepends a caller-supplied path prefix to
every OCI conformance repository name:
before conformance/{runID}/{slug-mapped}
after {prefix}/conformance/{runID}/{slug-mapped}An empty prefix, the default, yields the previous name byte-for-byte. This is purely additive: no existing target needs the flag.
Why
The OCI repository name was unreachable by configuration, and that made the
whole OCI catalog unrunnable against a registry that requires a fixed prefix
inside <name>.
Neither existing flag can supply one:
--registry-urlcannot, because the client inserts the/v2/literal between the base URL and the name. A prefix on the base URL lands on the wrong side of/v2/, and the registry's route needs/v2/as the first path segment.--run-idcannot, for two independent reasons.normalizeRunIDrewrites every non-alphanumeric character to-, so a run ID can never introduce a path separator; the name is therefore always exactly three segments. And Artifact Registry needs at least four (<slug>+container+<repository_name>+ at least one<image_name>). Three segments cannot satisfy a four-segment minimum even in the contrived case of a namespace whose slug is literallyconformance.
Artifact Registry routes
/v2/{slug}/container/{repository_name}/{image_name...} and requires the
literal container at segment index 1, where the suite puts the run ID. All
47 OCI rows failed at its router before reaching a handler.
Design notes for review
Prepending, not substituting. The conformance top-level prefix, the
run-ID segment and the slug-mapped segment keep their positions relative to
one another, so per-run isolation and the two-repository
oci.blob.cross-repo-mount pair keep working with no per-row changes.
repoNameFor instead of threading a parameter. Every per-AC Fn now calls
repoNameFor(e, runID, segment), which reads the prefix off the env, rather
than repoNameForRunID directly. That is 48 one-token call-site edits and
keeps the Fns prefix-unaware. An env that does not implement ociEnv
contributes no prefix, so the in-process fakes the row tests use are
untouched.
Grammar validation is in NewEnv, not at the flag boundary. This is the
one deliberate departure from how every other non-trivial flag here works, so
it is worth a reviewer's attention. The value must satisfy the OCI <name>
grammar, and architecture.md §Code placement puts
that grammar in the client layer and states the runner "does not know any
specific protocol". Validating in internal/cli would mean either importing
pkg/client/oci there, which no file in that package does, or copying
nameComponentRE, which CLAUDE.md forbids. So the OCI module checks it with
client.ValidateName and returns a *ConfigError, which the CLI already maps
to a "<field>: <reason>" stderr line and exit 2:
$ regconf run --format oci --oci-name-prefix 'AR-Registry/container' ...
create env: oci-name-prefix: each path segment must match the OCI <name>
grammar (lowercase alphanumeric runs joined by '.', '_', '__' or '-')
exit 2Same observable outcome as a flag Validator, one layer down.
Not registered on list. It changes how a run addresses a registry and
nothing about which rows exist, so list --oci-name-prefix is an
unknown flag rejection by the same rule that keeps --upstream-url off
that subcommand.
Spec change
S07 §Repository naming gains §Configurable name prefix, since that section
pinned the fixed shape this changes. It states the prepend-not-substitute
property, where the grammar is validated and why, and why --registry-url
cannot express this. The pre-existing table row now points at it.
Tests
pkg/conformance/oci/name_prefix_test.go, eight tests:
- the join, including that an empty prefix leaves the old form byte-for-byte
and that no
//or leading/is ever produced - the
normalizeRunIDerror still surfaces, and surfaces identically, with a prefix configured, so a prefix cannot mask it repoNameForreads the prefix off the env- an env without the accessor is unprefixed rather than an error, which is the in-process-fake path every row test depends on
NewEnvpropagates the value, and empty is the default- six rows of grammar rejection, each asserting the
*ConfigErrorfield is the kebab-case flag name - a property test walking every
repoSegment*constant, asserting the name built for each carries the prefix. It cannot prove each Fn callsrepoNameFor(only a compile-time ban could), but it does prove no segment is structurally exempt, so a missed call site surfaces as a wire-level 404 rather than a plausible-looking unprefixed name in a log.
Two existing tests updated for the new signature, and the two pinned flag inventories updated for the new flag.
go test ./... green across 18 packages; golangci-lint reports 0 issues;
the full pre-commit set passes.
Verified against a real registry
Run against Artifact Registry staging, which is the registry that motivated this:
| Before | After | |
|---|---|---|
| OCI rows executed | 0 of 47 | 47 of 47 |
| Passed | 0 | 32 |
| Failed | 0 | 11 |
| Skipped | 0 | 4 |
The router rejection and the handler answer are distinguishable by envelope,
which is what confirms the prefix is doing its job: the unprefixed name gets
the registry's own {"error":{"code":"not_found"}} from its router, while the
prefixed name gets the OCI {"errors":[{"code":"NAME_UNKNOWN"}]} from a
handler.
The 11 failures are all suite-side, not registry-side, and are filed separately so this MR stays one change:
- #57 (closed) —
Docker-Content-Digestis read from the response after the blob-GET redirect, where storage does not set it. 7 rows. - #58 (closed) — two not-found rows assert
MANIFEST_UNKNOWN/BLOB_UNKNOWNagainst a name nothing was pushed to, whereNAME_UNKNOWNis correct. 2 rows. - #59 (closed) — two manifest-
PUTrows expect400 MANIFEST_BLOB_UNKNOWNon a name with no prior blob upload and get404. The precedence is unsettled and needs a registry-side decision. 2 rows.
Worth stating plainly for anyone reading the 32/47 as a verdict on the registry: 15 rows produced no conclusion about it. Eleven failed before reaching their assertion and four did not run, so the honest reading is "no defect found in what was reached", not "the registry's OCI surface is clean". A re-run after #57 (closed) to #59 (closed) land is what would settle those.
The 4 skips are legitimate. Two are the suite's crane driver refusing a
Bearer credential (skipReasonBearerCredentialUnsupportedByCrane), which
matters here because this registry's /v2/ accepts only Bearer; crane is
installed and working, so "driver unavailable" would be the wrong reason to
record. The other two are conditional rows that cannot be observed against a
registry that supports deletion and serves the referrers API.