feat(oci): address a remote repository and its upstream on one host
Adds --oci-upstream-name-prefix, so a remote OCI run can address a registry
that serves the remote repository and the upstream it proxies on one host.
Closes the configuration half of
#60 (closed);
see What this does not do.
The problem
OCI puts /v2/ first in the path, so --registry-url can only be the host and
the repository lives inside <name>. --oci-name-prefix (!268 (merged)) is a single
value and cannot name both sides. On a registry serving both repositories on
one host the two base URLs are necessarily identical, and
Config.Validate rejected that before any request:
upstream-url: must differ from registry-url
exit 2The rejection was the right outcome, not the defect. Had it passed, one
prefix would have pointed both clients at the same repository, so seeding would
write into the repository under test and every relay row would pass with no
proxying having occurred. That silent green is worse than the exit 2.
Maven and npm carry the repository in the base URL, so their two URLs differ naturally and one shared name is correct. Neither needs an equivalent flag.
What this does
| Layer | Change |
|---|---|
internal/cli/flags.go, run.go |
--oci-upstream-name-prefix, run-only, REGISTRY_CONFORMANCE_OCI_UPSTREAM_NAME_PREFIX |
pkg/conformance/config.go |
Config.OCIUpstreamNamePrefix |
pkg/conformance/validate.go |
Two cross-flag rules; the URL-equality rule now reads the format |
pkg/conformance/oci/{env,module}.go |
ociEnv.UpstreamNamePrefix(), the one site resolving the empty-means-reuse default, and a shared grammar checker both prefixes go through |
docs/specs/S08 |
New §Addressing two repositories on one host, plus the flag, its validation, its error rows, and AC #4 (closed), #9 (closed), #12 (closed) and #38 (closed) |
docs/specs/S04, S07 |
The amendments S08's tables promise |
README.md |
The flag, the same-host invocation, and what its exit 0 is not evidence of |
The invocation the issue could not express:
registry-conformance run --format oci --repository-kind remote \
--registry-url "https://<ar-host>" \
--upstream-url "https://<ar-host>" \
--oci-name-prefix "<slug>/container/conformance-oci-remote" \
--oci-upstream-name-prefix "<slug>/container/conformance-oci-upstream" \
--credential "bearer:env:AR_TOKEN"Three decisions worth reviewing
Empty means reuse, not "no prefix". An unset --oci-upstream-name-prefix
resolves to --oci-name-prefix, so a registry whose two base URLs already
differ stays configurable with one flag. Resolving it to "" instead would
address the upstream at the /v2/ root of a registry that requires a prefix,
and the run would report a broken upstream rather than a configuration error.
The default is resolved once, in NewEnv.
The equality rule is lifted only when the two resolved names differ, not
for every OCI run. ociNamesDistinguishUpstream therefore tests emptiness
and inequality: an unset upstream prefix alongside a set --oci-name-prefix
differs textually while naming one repository, so reading the raw pair without
resolving the default would admit exactly the silent green above.
The rejection names the remedy the format has. Under OCI the Reason
becomes must differ from registry-url, or --oci-upstream-name-prefix must be set and differ from --oci-name-prefix; every other format keeps the
unqualified string. An OCI operator on a one-host registry has no second base
URL to point at, so the old wording read as "this registry shape is
unsupported". Neither arm carries URL bytes, per S08's no-echo rule.
Consumer note: that Reason change reaches an existing --format=oci
remote run with equal base URLs that sets no new flag. It is a
prefix-extension of the old string, so a substring match still hits and only an
exact-equality assertion breaks, and every affected run was already exiting 2.
Spec coverage
Spec: docs/specs/S08-remote-contracts.md, with the amendments it owns in docs/specs/S04-contracts.md.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| S08 AC #4 (closed) | URL-equality rejection, now lifted when two OCI names distinguish the repositories | TestConfig_Validate_IdenticalBaseURLsUnderOCI, TestConfig_Validate_IdenticalBaseURLsOutsideOCI, TestRunCommand_SameHostRemoteOCIRejections, TestRunCommand_SameHostRemoteOCIAccepted, TestRunCommand_NonOCIKeepsTheUnqualifiedEqualityRejection |
| S08 AC #9 (closed) | The flag resolves from REGISTRY_CONFORMANCE_OCI_UPSTREAM_NAME_PREFIX |
runFlagInventory row in internal/cli/flags_test.go |
| S08 AC #12 (closed), S04 AC #13 (closed) | list rejects the flag with unknown flag: |
TestListCommand_RejectsRunOnlyFlags |
| S08 AC #38 (closed) | Both cross-flag rejections, format reported before kind, and the same-host accept | TestConfig_Validate_OCIUpstreamNamePrefixCrossFieldRules, TestConfig_Validate_OCIUpstreamNamePrefixReasonsNameTheFlagToSet, TestRunCommand_OCIUpstreamNamePrefixCrossFlagRejections |
| §Addressing two repositories, property 1 | Empty means reuse, resolved once | TestNewEnv_ResolvesTheUpstreamNamePrefix, TestUpstreamNamePrefix_ZeroValueEnvIsEmpty |
| §Addressing two repositories, property 2 | Identical base URLs admitted only when the names differ | TestConfig_Validate_IdenticalBaseURLsUnderOCI, TestConfig_Validate_DifferingBaseURLsNeedNoSecondPrefix, TestRunCommand_DifferingBaseURLsKeepOneFlagWorking |
| §Addressing two repositories, property 3 | The rejection names the remedy the format has | TestConfig_Validate_EqualityReasonWordingPerFormat |
| §Addressing two repositories, property 4 | Each name bound to the client that addresses it | Not testable in this MR. No OCI remote row is on main, so there is no call site to bind. Pinned as a contract in S08 and now also in repoNameFor's doc comment, which is where the row author will read it. The row MR owes the test. |
| §Addressing two repositories, property 5 | The under-test name nests the upstream's path on a forwarding proxy | Not testable. The rule constrains the value an operator passes, not anything the suite computes; property 5 is itself the statement that the suite deliberately does not compute the nesting. Measured instead: Artifact Registry staging, 2026-09-04, all four combinations of upstream base URL and prefix nesting. |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | --oci-upstream-name-prefix under a non-oci format, exit 2 |
TestRunCommand_OCIUpstreamNamePrefixCrossFlagRejections, TestConfig_Validate_OCIUpstreamNamePrefixCrossFieldRules |
| E-2 | --oci-upstream-name-prefix under a resolved hosted kind, exit 2 |
same two, both kind spellings |
| E-3 | Equal base URLs, OCI, no differing prefix pair, exit 2 naming upstream-url |
TestRunCommand_SameHostRemoteOCIRejections (four spellings, including an explicitly empty flag) |
| E-4 | Equal base URLs, non-OCI, unqualified Reason kept |
TestRunCommand_NonOCIKeepsTheUnqualifiedEqualityRejection, TestConfig_Validate_IdenticalBaseURLsOutsideOCI |
| E-5 | Prefix outside the OCI <name> grammar, reported on its own field |
TestNewEnv_RejectsUpstreamPrefixOutsideTheNameGrammar, TestNewEnv_ReportsTheUnderTestPrefixOnItsOwnField, TestNewEnv_UnsetUpstreamPrefixInheritsNoSecondGrammarError |
| E-6 | --upstream-free-only with the flag set |
TestConfig_Validate_UpstreamFreeOnlyAcceptsTheUpstreamPrefix. Accepted and inert; the row pins the answer rather than asserting it is the right one. See below. |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | The relaxation must not carry the https-parity rule out with it, or the one credential sent to both base URLs reaches an http upstream in cleartext |
TestConfig_Validate_OCIRelaxationKeepsTheHTTPSParityRule |
| S-2 | No Reason carries URL bytes, on either arm of the new wording |
TestConfig_Validate_UpstreamURLReasonNeverEchoesURL, TestConfig_Validate_EqualityReasonWordingPerFormat |
| S-3 | The prefix reaches a URL path, so it must not escape <name> |
Grammar-checked in NewEnv via client.ValidateName, which rejects .., %2F and empty components: TestNewEnv_RejectsUpstreamPrefixOutsideTheNameGrammar |
What this does not do
This makes the run possible, not green, and it is the configuration half of
#60 (closed) only. The issue's Option B, binding each repository name to the client that
addresses it, is pinned as a contract in S08 §Addressing two repositories on
one host property 4 and in repoNameFor's doc comment, not enforced by a type
here, because no OCI remote row is on main: remote_seed.go and
remote_relay.go live in !266 (merged) and !271 (merged), both still Draft.
ociEnv.UpstreamNamePrefix() accordingly has no production caller yet, which
its doc comment says. The rows carry the bound pair when they arrive.
The README's same-host block says the same thing to operators: that
invocation's exit 0 is not proxying evidence today, because the run executes
the hosted catalog and never reads --upstream-url.
--upstream-free-only with --oci-upstream-name-prefix is accepted and
inert. That run has no upstream either, so the second name is never read,
which is the outcome the two cross-flag rules exist to refuse. Closing it is a
third cross-flag rule with its own S04 and S08 error rows, so it is left as a
spec decision rather than added here; the comments no longer claim a
completeness the rules do not have, and a test pins the current answer so a
later change to it is deliberate.
Expect #57 (closed) next on that track.
Conflicts to expect
Every branch in the OCI remote stack (!266 (merged) → !271 (merged) → !272 (merged) → !273 (merged) → !276 (merged))
predates !268 (merged) and still carries repoNameForRunID, which main replaced with
repoNameFor. Those branches need a rebase regardless of this MR. Once rebased
they overlap this diff in pkg/conformance/oci/{env,module}.go,
pkg/conformance/{config,validate}.go, internal/cli/{flags,run}.go and
README.md; !273 (merged) and !276 (merged) also touch docs/specs/S08-remote-contracts.md.
Landing this first keeps that to one rebase rather than two.
Testing
Mutation testing drove the validation and NewEnv tests: six mutations of the
validation rules and five of the NewEnv wiring, all caught. One mutation
initially survived, a grammar check reading the resolved value rather than the
raw Config field, and turned out to be unobservable because the under-test
check returns first; the comment claiming otherwise was corrected rather than a
test written for a case that cannot occur.
Review found a twelfth that did survive: hoisting ociNamesDistinguishUpstream
into an early return nil in compareUpstreamAgainstRegistry, which is the
natural tidy-up once that function grows a third branch, also skips the
https-parity check. Every OCI row with two differing prefixes used identical
base URLs, where parity cannot fire because equal URLs share a scheme, and the
only parity coverage was npm. TestConfig_Validate_OCIRelaxationKeepsTheHTTPSParityRule
closes it.
internal/cli: the two cross-flag rejections at the operator surface, the same-host accept and reject matrices, the differing-base-URL run that still needs only one flag, npm keeping the unqualified rejection (including with the upstream prefix set, which is the row that catches a relaxation missing its format test), and the flag-to-Configmapping row.pkg/conformance: the same rules atConfig.Validate, bothReasonwordings per format, the https-parity row above, the--upstream-free-onlyrow, and the accept rows that a rule which over-rejects would fail.pkg/conformance/oci: the resolution matrix through the accessor, the grammar rejection on its own field in both directions, and the zero-value contract for anenvliteral.
golangci-lint run --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false ./... reports 0 issues. Full go test -race ./... is
green.
No conformance row changed, so no catalog or inventory edit applies.
Related to #60 (closed)