fix(oci): address the upstream repository by its own name

Makes the OCI remote rows honour --oci-upstream-name-prefix, so a remote run reads through the repository under test and seeds the upstream when a registry serves both on one host. Closes #60 (closed); !277 (merged) landed the configuration half.

The problem

!277 (merged) added the flag and lifted the URL-equality rejection for a pair whose two OCI names differ, but nothing read the second prefix. newRemoteFixture built one name through repoNameFor and handed it to the upstream seeding write and to the under-test relay read alike, so the gate was open and the road was not paved: a run configured for one host reached the outcome the rejection exists to prevent, through a call site instead of through configuration. Seeding wrote into the repository under test, the relay read the same coordinate back, and oci.remote.preflight passed with no proxying having occurred.

Nothing in a passing report shows that, which is why S08 §Addressing two repositories on one host property 4 asks for a type rather than a convention.

What this does

Each name travels paired with the client that addresses it.

Layer Change
pkg/conformance/oci/env.go repoSide (client + prefix) and repoTarget (client + resolved name). ociEnv.UnderTest() and ociEnv.Upstream() each answer a repoSide, replacing NamePrefix(), Upstream() *client.Client and UpstreamNamePrefix(). repoNameFor takes the side; repoTargetFor pairs the resolved name with that side's client
pkg/conformance/oci/remote_seed.go remoteFixture carries one repoTarget per side. remoteSeedSpec, seedImage, seedBlob, blobPresent, manifestPresent, seedManifest and readBackManifest take no client argument of their own
pkg/conformance/oci/remote_preflight.go The row's own read goes through fx.UnderTest. The failure message names both coordinates on each arm, and leads with them on the read-failure arm so the end-of-line value group in redact.ScanContent cannot take the pair with a refusal body
22 hosted row files, plus two of their tests repoNameFor(e, …) becomes repoNameFor(underTest(e), …), one token each
docs/specs/S08-remote-contracts.md Property 4 records the implemented shape; §Downstream amendments gains its row
docs/plans/2026-08-21-remote-conformance.md §Naming Conventions names the two types, so Steps 31-37 take a target
README.md Says the preflight's 404 MANIFEST_UNKNOWN names both coordinates, so an operator can check them against the two prefixes they passed
pkg/conformance/oci/remote_{relay,blob,head,manifest}.go and their tests Merge with !271 (merged), whose seven relay rows addressed the fixture through the single RepoName these two targets replace. Each site is read rather than renamed: a relay read takes fx.UnderTest, and the two origin-store test helpers take fx.Upstream. The eleven relay reads take the target's own client, and relayRow picks up the new newRemoteFixture and remoteSeedSpec signatures

Three decisions worth reviewing

One builder, and the side is a parameter with no default. repoNameFor stays the package's only repository-name function. The alternative, an upstream-side sibling or a prefix-taking helper both sides call, is the split ae1252f already paid for: a call site can reach the wrong one and still compile, still pass. Taking a repoSide means naming a side is the only way to build a name at all, and the prefix cannot arrive apart from the client it belongs to.

Two types rather than one. repoSide is per-run (client + prefix) and repoTarget is per-row (client + resolved name). Collapsing them would leave a half-built value whose name is empty until someone resolves it, and the row-facing half is the one that has to be indivisible.

Client() stays on ociEnv. A hosted row addresses one repository and has no second name to confuse it with, so it takes the client directly; only a remote row takes the pair. Its doc comment says so, so the two routes read as a distinction rather than as a duplicate.

Spec coverage

# Criterion Tests
§Addressing two repositories, property 4 Each name bound to the client that addresses it. !277 (merged) recorded this as "the row MR owes the test" TestNewRemoteFixture_AddressesTheUpstreamByItsOwnName, TestSeedImage_AddressesOnlyTheUpstreamRepository, TestTestRemotePreflight_ReadsAndSeedsTwoDifferentRepositories, TestUpstreamSide_PairsTheResolvedPrefixWithTheUpstreamClient
§Addressing two repositories, property 1 Empty means reuse, now observed end to end on the fixture rather than only on the accessor TestNewRemoteFixture_CarriesTheConfiguredNamePrefix (both sides, through NewEnv)
AC #24 (closed), §Preflight The <observed> clause names both coordinates on both failure arms, labelled seeded and read, and the pair survives the message scrub when the refusal body carries one of the five patterns TestRelayReadVerdict_MessageNamesBothCoordinates, TestTestRemotePreflight_FailsWhenTheRemoteServesOtherBytes, TestRelayReadVerdict_CoordinatesSurviveTheScrubOnARefusalBody, TestRemoteCoordinates_LabelsTheSeededAndTheReadSide
S07 §Configurable name prefix The under-test name still carries the prefix at every call site TestRepoNameFor_EveryRepoSegmentConstantIsPrefixed, TestRepoNameFor_ReadsPrefixFromTheSide, TestUnderTest_EnvWithoutAccessorIsUnprefixed

Ten test functions are added and three removed; three of the additions rename the removals, so seven are new. Each was run against the mutation it exists to catch:

  • Building the upstream target from UnderTest() fails TestNewRemoteFixture_AddressesTheUpstreamByItsOwnName and TestSeedImage_AddressesOnlyTheUpstreamRepository, among others.
  • Reading the relay through fx.Upstream.repoName fails TestRelayReadVerdict_MessageNamesBothCoordinates and TestTestRemotePreflight_ReadsAndSeedsTwoDifferentRepositories: two tests, not one.
  • Appending the coordinate pair after the <observed> clause rather than ahead of it fails TestRelayReadVerdict_CoordinatesSurviveTheScrubOnARefusalBody, with both names gone from the message.
  • Deleting the pair from the byte-mismatch arm fails TestTestRemotePreflight_FailsWhenTheRemoteServesOtherBytes. Before this round that deletion left the whole tree green.

Why the diff is this size

1403 changed lines against the 500 ceiling, and splitting does not reach it. 110 of them are the mechanical repoNameFor(e, …) swap across 22 hosted row files and two of their tests, which the signature change forces in the same commit that makes it: a separate MR for those does not compile on its own, and taking them out still leaves 1293. A further 62 are the merge with !271 (merged), which cannot be deferred either: its relay rows address the fixture through the field this MR replaces, so the tree does not compile until every one of those sites picks a side. The remainder is one change, not several: the two types, the fixture that carries them, the tests that pin each mutation, and the spec and doc statements the new shape makes false. Reviewing it in pieces would mean reviewing a type against call sites that do not yet use it.

Upgrade note

Any remote OCI run that set --oci-upstream-name-prefix to a value differing from --oci-name-prefix changes behavior here, two-host runs included and not only the same-host shape. Such a run previously seeded and read one name and passed; it now seeds the upstream name and 404s unless the two prefixes nest the way S08 §Addressing two repositories on one host property 5 requires. That is the point of the change, since the old pass established nothing about proxying.

No ! on the title: the run whose behaviour changes was passing on a false green, so no documented guarantee is withdrawn. main's README said a CI gate needing a proxying claim had to wait for the remote rows; since !271 (merged) it says a remote run's exit 0 is proxying evidence only to the extent the two prefixes name two genuinely linked repositories, which is the case this MR makes fail rather than pass. The release note should still carry the paragraph above, because a run that was green yesterday can be red today.

What this does not do

  • Property 5's nesting stays the operator's. The suite does not compute {slug}/{repoUnderTest}/{slug}/{repoUpstream}; remotefake forwards the incoming name verbatim, so the row-level test asserts which repository each channel addressed and not the relay's verdict.
  • The band stops at Step 33. !271 (merged)'s seven rows plus the two kind-independent ones are what a remote OCI run executes; the wiring for remote_listings.go and the rest lands with Steps 34-37, which now find the target shape in place.

🤖 Generated with Claude Code

Edited by Sylvia Shen

Merge request reports

Loading
Loading