Skip to content

feat: Pull images through containerd on Linux - #127

Merged
cmainas merged 5 commits into
mainfrom
feat/images_pull
Oct 23, 2026
Merged

cmainas merged 5 commits into
mainfrom
feat/images_pull

Conversation

@cmainas

@cmainas cmainas commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

On Linux, hull keeps its images in containerd, and the only verb so far was
hull images, which can list images but has no way to get one there. This
adds hull pull. It takes the same arguments as the macOS pull and prints
the same lines, with progress on stderr.

The pull runs in hull's process through containerd's client, not through
the transfer service in the daemon. The daemon has none of the user's
registry credentials. hull reads them the way it does on macOS, from the
Docker config and its credential helpers, through an adapter from
go-containerregistry's keychain to containerd's authorizer. A credential
helper that fails, as one needing a locked keychain does in a headless
session, leaves the pull anonymous, on the same test of the error the macOS
pull uses.

The registry client matches the macOS one where the two would otherwise
differ:

  • It pins HTTP/1.1. Large ghcr layers fail part-way over Go's HTTP/2 stack,
    which is why pkg/ociclient pins it too.
  • It tries plain HTTP after HTTPS for the registries go-containerregistry
    allows that for: loopback, RFC 1918 addresses and .local names. The rule
    is taken from go-containerregistry itself, so a local registry works on
    both OSes.
  • It maps containerd's Docker Hub host, registry-1.docker.io, to
    index.docker.io, where docker login stores Hub's credentials.

containerd unpacks the image into a snapshot as part of the pull, so the
image is ready for hull run once that verb exists.

Where Linux differs from macOS

These are documented under "Building on Linux" in docs/build.md:

  • --platform defaults to the host's (linux/amd64 or linux/arm64), not
    to linux/arm64, so an amd64 host pulls an image it can boot.
  • Images are stored under their full name: hull pull busybox lists as
    docker.io/library/busybox. containerd's resolver needs the registry
    spelled out, and ctr and nerdctl use the same names.
  • The daemon does the unpacking, so its user needs permission to create the
    snapshot. A containerd run as an ordinary user, outside rootlesskit, cannot
    unpack, and the pull fails there.
  • Progress counts bytes downloaded, not extracted. containerd reports
    nothing to the client while it unpacks.
  • The macOS pull's store upkeep has no counterpart here and none was added:
    re-unpacking a damaged image, keeping an image that instances use,
    sweeping staging leftovers, and carrying an index digest over between
    pulls. containerd's content store and garbage collector cover most of it.
    Whether containerd keeps an image an instance uses only matters once run
    exists.

The requested platform must match exactly, so an index without it fails the
pull instead of yielding a compatible platform. The result is described with
a new DescribePlatform, which considers only that platform's manifests.
DescribeImage would rank an earlier pull's complete image of another
platform first on a containerd that discards layers once it has unpacked
them.

Related issues

Refs #105. Depends on the hull images PR (feat/images), whose commits are
at the base of this branch.

Changes

  • internal/imageplatform (refactor, its own commit): the --platform
    parser moves out of pkg/ociclient, so both pulls accept the same strings
    and fail the same way without the Linux CLI linking the macOS pull code.
    It returns an OCI platform, which pkg/ociclient converts to
    go-containerregistry's. Nothing changes on macOS.
  • hull pull on Linux (cmd/hull/pull_linux.go): the macOS flags and
    output, with --platform parsed by internal/imageplatform.
  • internal/containerd/pull.go: Pull, NormalizeRef, the resolver and
    the credentials adapter. containerd's client logs a failed registry request
    at info level and then returns the same failure, so its info messages are
    dropped unless --debug is given.
  • internal/containerd/progress.go: the progress line, which redraws in
    place on a terminal. Otherwise it prints a line every 5s and when a layer
    finishes, as on macOS. It learns
    the layers from the manifest and finds each download by its ingest ref in
    the content store, which does not report the digest an ingest expects.
  • internal/containerd/images.go: DescribePlatform.
  • docs/build.md: hull pull and how it differs from macOS.
  • go.mod: containerd/log and distribution/reference move from indirect
    to direct dependencies.
  • Tests: unit tests for the credentials adapter, the plain-HTTP rule,
    reference normalization, DescribePlatform and the progress output. The
    parser's tests move with it. Two daemon tests pull
    ghcr.io/containerd/busybox:1.36. One goes through a credential helper
    that always fails and checks that the pull went ahead anonymously and left
    the image unpacked. The other runs hull pull and hull images through the
    command tree and checks what they print. These are the first tests that
    reach a registry, so unit-linux now needs network access to ghcr.io.

Checklist

  • make test passes
  • make macos builds hull, vz-runner and hvi, if Go, Swift or Rust code changed
  • I have added or updated tests covering the change
  • I have run the e2e harnesses (test/*.py) for changes touching boot, console, or the run path
  • I have updated the affected docs (README, docs/)

The Linux pull is to accept the same --platform strings as the macOS pull
and fail the same way on the rest. The parser lived in pkg/ociclient,
which the Linux CLI does not link: it is the macOS store's pull and
unpack code, built on go-containerregistry, while the Linux pull goes
through containerd.

internal/imageplatform now holds the parser and returns an OCI platform,
the type containerd takes. pkg/ociclient converts it to
go-containerregistry's. Nothing changes on macOS.

Refs: #105

Signed-off-by: Charalampos Mainas <cmainas@nofire.ai>
imageplatform.Parse checked only the number of parts, so "linux/" parsed
as a platform with no architecture. On a multi-platform image the pull
then failed at the registry, with a message about the index rather than
the flag. A single-platform image has no index to match against, so the
macOS pull fetched it whatever its architecture and recorded its platform
as "linux/".

Parse now refuses an empty OS, architecture or variant with the message it
gives any other malformed --platform, before anything is fetched.

Refs: #105

Signed-off-by: Charalampos Mainas <cmainas@nofire.ai>
The Linux pull is to report progress the way the macOS pull does, but the
code that draws the line lived in pkg/ociclient, which the Linux CLI does
not link.

internal/pullprogress now draws the line: whether it refreshes in place or
prints a line at a time, how often, and the final line. Each pull keeps its
own counting and writes its own text, because the two count different
things. The macOS pull counts bytes as hull extracts them, one layer after
another. containerd downloads layers concurrently and unpacks them itself,
so the Linux pull can only count what has been downloaded.

The macOS output does not change.

Refs: #105

Signed-off-by: Charalampos Mainas <cmainas@nofire.ai>
`hull pull` printed each label of the image as it came. Labels are chosen
by whoever built the image, so a value could break the "key: value"
layout with newlines, as the description of Ubuntu's image does, or carry
an escape sequence to the terminal: OSC 52 reads and writes the
clipboard, and CSI 21t types its reply into the shell's input.
`hull images --json` already escapes labels for the same reason (see
printJSON).

writeLabels prints a key or value as it is only when it is valid UTF-8
made of printable characters and spaces, and Go-quotes it otherwise, so
newlines, escape sequences, C1 controls, invalid bytes and bidi overrides
show as escapes. A value that starts with a double quote is quoted too,
so a quoted value cannot be mistaken for a plain one. Keys are sorted
rather than printed in map order.

writeLabels has no build tag, so the Linux pull can use it as well.

Refs: #105

Signed-off-by: Charalampos Mainas <cmainas@nofire.ai>
@cmainas

cmainas commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

@claude-fleet review

@claude-fleet

claude-fleet Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review task queued.

Task: 5f09882b-31e9-49bc-8e29-dfd50c58c5a6
Commit: 3f40285
Model: claude-opus-5 (effort medium)
Executor: rp2
Status: done. Review posted.

@claude-fleet claude-fleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: two should_fix findings. The pull path itself checks out — I verified the credential, HTTP/1.1, plain-HTTP and namespace claims against the dependency source, and the revert checks confirm the three fixes are covered by tests.

Findings:

  • should_fix, internal/containerd/pull_test.go:376 — the platform assertion fails on an arm64 Linux host
  • should_fix, internal/containerd/images.go:92 — DescribePlatform drops index entries with no platform, so a pull that succeeded reports an error
  • nit, internal/containerd/pull.go:201 — registrytoken in the Docker config is dropped, unlike the macOS pull

Verified:

  • The plain-HTTP rule is go-containerregistry's own, RFC 1918 included — Registry.Scheme at pkg/name/registry.go:72,88-105 of go-containerregistry@v0.20.6, against pull.go:163-169
  • HTTP/2 really is off: docker.DefaultHTTPTransport sets DialContext and TLSClientConfig and leaves ForceAttemptHTTP2 false (containerd v2.3.5 core/remotes/docker/registry.go:254-269), which is exactly the case net/http/transport.go:477 skips auto-enabling h2 for — pull.go:147-156
  • docker login files Hub under index.docker.io while containerd pulls from registry-1.docker.io: ConfigureDefaultRegistries rewrites docker.io to registry-1.docker.io — pull.go:184-188
  • The raw ctx handed to prog.watch and DescribePlatform still carries the namespace: client.New installs the NS interceptors for defaultns (client/client.go:159-161), so ListStatuses is not silently erroring — pull.go:82,92
  • The unpacker fetches layers through the wrapped handler (unpacker.Unpack(wrapper(h)) and u.unpack(uctx, h, ...), client/pull.go:144-148) and skips a layer whose snapshot exists (core/unpack/unpacker.go:418-425), which is what progress.go:68-71 and images.go:65-70 claim
  • MakeRefKey depends on the context only through WithMediaTypeKeyPrefix, which nothing in the pull path sets, so the refs registered in wrap are the refs the fetch handler opens its ingests under — core/remotes/handlers.go:79-92,141 against progress.go:84,131
  • The docs' snapshotter claim: resolveSnapshotterName reads defaults.DefaultSnapshotterNSLabel and falls back to overlayfs — client/client.go:909-916

Checks run:

  • go test ./internal/containerd/... ./internal/pullprogress/... ./internal/imageplatform/... ./cmd/hull/... → ok (the two daemon tests skip, no HULL_TEST_CONTAINERD_ADDRESS here)
  • Removed slices.Contains(parts, "") from imageplatform.Parse → TestParsePlatform fails on 4 subtests (linux/, /amd64, linux//v8, linux/arm64/)
  • Made printableLabel return s unquoted → TestPrintableLabel fails on 7 inputs and TestWriteLabels on its whole output
  • Disabled the onlyPref filter in describeImage → both TestDescribePlatform* tests fail
  • GOOS=darwin GOARCH=arm64 go build ./... → ok; go vet on the touched packages → clean
  • Working tree restored after each revert (git status --porcelain shows only untracked node_modules)

Smaller things:

  • The Changes list omits two commits that are on the branch: internal/pullprogress (the progress line moved out of pkg/ociclient) and cmd/hull/imagelabels.go. The latter changes the macOS pull's output too — labels carrying controls are now Go-quoted — while the description only says "Nothing changes on macOS" about the imageplatform move. Worth a line, since it is the user-visible part a macOS reader would otherwise miss.
  • pull.go:68: name, err := NormalizeRef(ref) shadows the imported name package for the rest of Pull. It compiles because Pull does not use the package, but the two name. uses a few lines below in keychainCreds read oddly next to it.
  • The credential-helper-failure rule is now explained in three places — pkg/ociclient/client.go:110-113, internal/containerd/pull.go:177-181, docs/build.md — and the HTTP/1.1 pin in two. The new copies do point at the originals, so this is only a note that the next round should not add a fourth.
  • No test covers the HTTP/1.1 pin or that newResolver routes a plain-HTTP-allowed host to the fallback client; only allowsPlainHTTP in isolation is tested. Given the pin exists because of a real ghcr failure, a test asserting NextProtos/ForceAttemptHTTP2 on the transport would be cheap insurance against a containerd default changing underneath it.
  • go-containerregistry does not try HTTPS first for the hosts its Scheme() calls http — it goes straight to HTTP. The Linux path adds one failed HTTPS round trip per host before falling back. Same outcome, slightly slower; the comment at pull.go:158-162 reads as if the two are identical.

Not checked:

  • TestPullThroughDaemon and TestPullCommandAgainstDaemon against a live containerd — no daemon in this environment, so neither the unpack assertion nor the hull images table assertion ("ghcr.io/containerd/busybox 1.36", which depends on column widths) was exercised.
  • docs/build.md prose beyond the four claims listed under Verified.

Not checked:

  • TestPullThroughDaemon and TestPullCommandAgainstDaemon against a live containerd daemon: none available here, so the unpack assertion and the hull images table assertion were not exercised
  • docs/build.md prose beyond the platform default, full-name storage, snapshotter label and plain-HTTP claims listed under Verified

Task 5f09882b-31e9-49bc-8e29-dfd50c58c5a6 on rp2, model claude-opus-5, effort medium.

Comment thread internal/containerd/pull_test.go Outdated
if got.IndexDigest == "" || got.Digest == "" || got.Digest == got.IndexDigest {
t.Errorf("digests = %q (index %q), want a platform manifest under an index", got.Digest, got.IndexDigest)
}
if got.Platform != platforms.Format(platform) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should_fix: The platform assertion compares a normalized request against an un-normalized image config, so it fails on an arm64 Linux host.
Failure: on arm64, platforms.DefaultSpec() returns linux/arm64/v8 and line 361 clears the variant, so platforms.Format(platform) is "linux/arm64". got.Platform comes from images.go:124-126, which formats the image config's own platform without normalizing, and ghcr.io/containerd/busybox:1.36's arm64 config carries "variant": "v8" — so got.Platform is "linux/arm64/v8" and the test reports platform = "linux/arm64/v8", want "linux/arm64". CI runs this job on ubuntu-latest (amd64), where the config has no variant and the comparison holds, so the failure only shows on an arm64 Linux box — make test on one, which the Makefile supports via LINUX_ARCH=arm64.
Evidence: pull_test.go:360-361 clears the variant; images.go:124-126 formats config.Platform as read; platforms.Format is path.Join(OS, Architecture, Variant) with no normalization (containerd/platforms@v1.0.0-rc.5/platforms.go:386-392). I confirmed platforms.DefaultSpec() is "linux/arm64/v8" on this aarch64 host, and fetched the image's arm64 config blob (sha256:abaa813f…) from ghcr: {'architecture': 'arm64', 'os': 'linux', 'variant': 'v8'}.
Fix: compare normalized platforms, e.g. platforms.Format(platforms.Normalize(...)) on both sides, or keep platforms.DefaultSpec() intact and let OnlyStrict normalize the request as it already does.

Comment thread internal/containerd/images.go Outdated
if onlyPref {
manifests = nil
for _, m := range idx.Manifests {
if m.Platform != nil && pref.Match(*m.Platform) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should_fix: The onlyPref filter requires a non-nil Platform on every index entry, but containerd's own filter treats a missing platform as a match, so a pull that succeeded ends in an error.
Failure: for an index whose entries omit the optional platform field, images.FilterPlatforms keeps those children (if d.Platform == nil || m.Match(*d.Platform)), containerd fetches the manifest and the unpacker unpacks it with platforms.All, so c.Pull returns success. DescribePlatform then filters every entry out, pickManifest gets an empty slice, and hull pull exits non-zero with "no image manifest of the index is in the content store" — while hull images, which goes through DescribeImage, lists the image fine. Narrow: Docker, BuildKit and podman all set platform on index entries, so this needs an index built by something that leaves it out.
Evidence: images.go:91-96 against containerd v2.3.5 core/images/handlers.go:292-313 (FilterPlatforms) and core/unpack/unpacker.go reached via client/pull.go:108-112, where platformMatcher is platforms.All; images.go:186-191 produces the message.
Fix: match containerd — if m.Platform == nil || pref.Match(*m.Platform). A nil-platform candidate gets matches: false at images.go:183, so the pref.Less call at images.go:200 stays unreachable for it (the a.matches != b.matches branch returns first).

Comment thread internal/containerd/pull.go Outdated
if cfg.IdentityToken != "" {
return "", cfg.IdentityToken, nil
}
return cfg.Username, cfg.Password, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The keychain adapter forwards only IdentityToken and Username/Password, dropping AuthConfig.RegistryToken, so a registry authenticated that way works on macOS and goes anonymous on Linux.
Failure: with a registrytoken entry for a private registry in the Docker config, authn.DefaultKeychain resolves it and Authorization() returns an AuthConfig whose only populated field is RegistryToken. This function then falls through to return cfg.Username, cfg.Password, nil — both empty — so containerd's authorizer sends no credentials and the pull fails with a 401 or a "not found". The macOS pull sends it as Authorization: Bearer <token>.
Evidence: pull.go:194-202; go-containerregistry@v0.20.6 reads the field in both pkg/v1/remote/transport/basic.go and bearer.go (see transport/basic_test.go:61-68, bearer_test.go:199-212), and defaultKeychain.Resolve copies RegistryToken out of the Docker config.
Since containerd's creds callback has no bearer channel, the honest options are to document the gap next to the IdentityToken comment or to return an error naming it, rather than silently pulling anonymously.

`hull pull` on Linux fetches an image into hull's containerd namespace and
prints what the macOS pull prints. The pull runs in hull's process through
containerd's client, not through the transfer service in the daemon: the
daemon has none of the user's registry credentials, and hull reads them
from the Docker config and its credential helpers, as on macOS. A helper
that fails, as one needing a locked keychain does in a headless session,
leaves the pull anonymous, on the same test of the error the macOS pull
uses.

containerd's credentials callback can only return a username and a secret,
so a registry token in the Docker config, which the macOS pull sends as a
bearer token on every request, goes out as a header of that registry's
host instead. The keychain is asked once per registry for the life of the
resolver, since containerd asks for a host's configuration on every
request and a credential helper can be slow or prompt.

The registry client matches the macOS one where the two would otherwise
differ. It pins HTTP/1.1, because large ghcr layers fail part-way over Go's
HTTP/2 stack (see registryTransport in pkg/ociclient). It tries plain HTTP
after HTTPS for the registries go-containerregistry allows it for, taken
from go-containerregistry itself, so a local registry works on both OSes.
containerd pulls Docker Hub from registry-1.docker.io, but `docker login`
files Hub's credentials under index.docker.io, so the credentials lookup
maps one to the other.

Some differences follow from containerd and the host:

- --platform defaults to the host's architecture rather than linux/arm64,
  so an amd64 host pulls an image it can boot.
- The image is stored under its full name, docker.io/library/busybox for
  `busybox`, which containerd's resolver needs and ctr and nerdctl use.
- The daemon unpacks the image into a snapshot, so the progress line
  counts bytes downloaded rather than extracted. It is drawn by
  internal/pullprogress, as the macOS line is.

containerd's unpacker does not download a layer whose snapshot already
exists, such as a base layer shared with an image pulled before. A
successful pull can therefore leave the requested platform's layers
missing, and its handlers never see those layers finish. The result is
described with DescribePlatform, which considers only the index entries
the pull can have fetched, where DescribeImage would rank an earlier
pull's complete image of another platform first. Those are the entries
that match, or the entries that name no platform when none matches:
containerd keeps an entry without a platform but sorts it after the rest.
The progress line counts every layer once the pull succeeds, with the
bytes of only those it fetched.

The platform must match exactly, so an index without it fails the pull
instead of yielding a compatible platform.

containerd's client logs a failed registry request at info level and then
returns the same failure, so its info messages are dropped unless --debug
is given.

The unit-linux job now pulls containerd's busybox from ghcr.io twice. One
pull goes through a credential helper that always fails, and checks that
it went ahead anonymously and left the image unpacked. The other runs
`hull pull` and `hull images` through the command tree and checks what
they print.

Refs: #105

Signed-off-by: Charalampos Mainas <cmainas@nofire.ai>
@cmainas

cmainas commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

@claude-fleet review

@claude-fleet

claude-fleet Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review task queued.

Task: cca5550b-f2fa-4bd8-9ec6-f8d30e100441
Commit: f07cc0a
Model: claude-opus-5 (effort medium)
Executor: ci-16gb-fsn1-1
Status: done. Review posted.

@claude-fleet claude-fleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous round at 3f40285: 3 findings.

Previous finding Classification Cited line
internal/containerd/pull_test.go:376 (should_fix): The platform assertion compares a normalized request against an un-normalized image config, so it fails on an arm64 Linux host. Failure: on arm64, platforms.DefaultSpec() returns linux/arm64/v8 and line 361 clears the variant, so platforms.Format(platform) is "linux/arm64". got.Platform comes from images.go:124-126, which formats the image config's own platform without normalizing, and ghcr.io/containerd/busybox:1.36's arm64 config carries "variant": "v8" — so got.Platform is "linux/arm64/v8" and the test reports platform = "linux/arm64/v8", want "linux/arm64". CI runs this job on ubuntu-latest (amd64), where the config has no variant and the comparison holds, so the failure only shows on an arm64 Linux box — make test on one, which the Makefile supports via LINUX_ARCH=arm64. Evidence: pull_test.go:360-361 clears the variant; images.go:124-126 formats config.Platform as read; platforms.Format is path.Join(OS, Architecture, Variant) with no normalization (containerd/platforms@v1.0.0-rc.5/platforms.go:386-392). I confirmed platforms.DefaultSpec() is "linux/arm64/v8" on this aarch64 host, and fetched the image's arm64 config blob (sha256:abaa813f…) from ghcr: {'architecture': 'arm64', 'os': 'linux', 'variant': 'v8'}. Fix: compare normalized platforms, e.g. platforms.Format(platforms.Normalize(...)) on both sides, or keep platforms.DefaultSpec() intact and let OnlyStrict normalize the request as it already does. fixed internal/containerd/pull_test.go:494
internal/containerd/images.go:92 (should_fix): The onlyPref filter requires a non-nil Platform on every index entry, but containerd's own filter treats a missing platform as a match, so a pull that succeeded ends in an error. Failure: for an index whose entries omit the optional platform field, images.FilterPlatforms keeps those children (if d.Platform == nil || m.Match(*d.Platform)), containerd fetches the manifest and the unpacker unpacks it with platforms.All, so c.Pull returns success. DescribePlatform then filters every entry out, pickManifest gets an empty slice, and hull pull exits non-zero with "no image manifest of the index is in the content store" — while hull images, which goes through DescribeImage, lists the image fine. Narrow: Docker, BuildKit and podman all set platform on index entries, so this needs an index built by something that leaves it out. Evidence: images.go:91-96 against containerd v2.3.5 core/images/handlers.go:292-313 (FilterPlatforms) and core/unpack/unpacker.go reached via client/pull.go:108-112, where platformMatcher is platforms.All; images.go:186-191 produces the message. Fix: match containerd — if m.Platform == nil || pref.Match(*m.Platform). A nil-platform candidate gets matches: false at images.go:183, so the pref.Less call at images.go:200 stays unreachable for it (the a.matches != b.matches branch returns first). fixed internal/containerd/images.go:135
internal/containerd/pull.go:201 (nit): The keychain adapter forwards only IdentityToken and Username/Password, dropping AuthConfig.RegistryToken, so a registry authenticated that way works on macOS and goes anonymous on Linux. Failure: with a registrytoken entry for a private registry in the Docker config, authn.DefaultKeychain resolves it and Authorization() returns an AuthConfig whose only populated field is RegistryToken. This function then falls through to return cfg.Username, cfg.Password, nil — both empty — so containerd's authorizer sends no credentials and the pull fails with a 401 or a "not found". The macOS pull sends it as Authorization: Bearer <token>. Evidence: pull.go:194-202; go-containerregistry@v0.20.6 reads the field in both pkg/v1/remote/transport/basic.go and bearer.go (see transport/basic_test.go:61-68, bearer_test.go:199-212), and defaultKeychain.Resolve copies RegistryToken out of the Docker config. Since containerd's creds callback has no bearer channel, the honest options are to document the gap next to the IdentityToken comment or to return an error naming it, rather than silently pulling anonymously. fixed internal/containerd/pull.go:153

No findings.

Verified both fixes by reverting them; all three previous findings are addressed. No new findings.

Approve.

Previous findings:

  • 1 fixed — internal/containerd/pull_test.go:490-496 parses got.Platform and compares through platforms.OnlyStrict(platform).Match, so both sides normalize. normalizeArch maps arm64/v8 to arm64 with an empty variant (platforms@v1.0.0-rc.5 database.go:87-94), and matcher.Match normalizes its argument (platforms.go:189-194), so the arm64 config's v8 no longer breaks the assertion.
  • 2 fixed — internal/containerd/images.go:135-149 (pulledManifests) keeps entries without a platform when none matches, which is what containerd's own chain does: FilterPlatforms keeps nil-platform children (core/images/handlers.go:304-308) and LimitManifests sorts them last and takes the first (handlers.go:330-347, limit 1 via client/pull.go:234-235). Preference for a matching entry is kept, so an earlier pull's complete nil-platform entry cannot outrank it.
  • 3 fixed — internal/containerd/pull.go:149-157 resolves the credentials in the Hosts callback and puts Authorization: Bearer <registrytoken> on each RegistryHost, merged into every request at core/remotes/docker/resolver.go:551-556. The overwrite loses nothing: no WithHeaders is used, and the resolver's own User-Agent lives in r.header, not host.Header.

Verified:

  • The keychain is consulted once per registry across repeated callbacks, under either Hub name — internal/containerd/pull.go:214-246, covered by pull_test.go:140-150.
  • A failing credential helper still leaves the pull anonymous after the refactor, and a config that does not parse still fails it — pull.go:234-240, pull_test.go:155-167.
  • pulledManifests's claim about containerd's ordering matches core/images/handlers.go:292-350 and client/pull.go:225-236.
  • cmd.ErrWriter is never nil for the Linux pull, so progress reaches stderr and the tty check works: cli v3 inherits it from the parent or defaults to os.Stderr (command_setup.go:68-74).

Checks run:

  • go test ./internal/containerd/... ./internal/pullprogress/... ./internal/imageplatform/... ./cmd/hull/... → ok (the two daemon/ghcr tests do not run here; no daemon).
  • Reverted images.go:91 to the old nil-rejecting filter → TestDescribePlatformOfAnIndexWithoutPlatforms fails with "no image manifest of the index is in the content store"; restored.
  • Disabled the cfg.RegistryToken != "" branch at pull.go:153 → TestResolverSendsTheRegistryToken fails with registry with a token got Authorization ""; restored (git status clean apart from untracked node_modules).
  • GOOS=darwin GOARCH=arm64 go build ./... → ok, so the imageplatform extraction does not break the macOS side.

Smaller things:

  • images.go:120-122 formats the image config's platform without normalizing, so on an arm64 host hull pull/hull images print linux/arm64/v8 where an amd64 host prints linux/amd64. Pre-existing (it comes from the images change), cosmetic, and the test now tolerates it — worth one platforms.Normalize someday.
  • A registry token for an RFC 1918 or .local registry can go out over the plain-HTTP fallback. go-containerregistry does the same on macOS, so the two OSes stay level.

Not checked: the two tests that reach ghcr.io (TestPullThroughDaemon, TestPullCommandAgainstDaemon) did not run — no containerd daemon in this workspace; their logic I read only.

Not checked:

  • internal/containerd/pull_test.go TestPullThroughDaemon and cmd/hull/pull_linux_test.go TestPullCommandAgainstDaemon did not execute: no containerd daemon in this workspace, so the ghcr.io pull path was read but not run
  • cmd/hull/imagelabels_test.go and internal/pullprogress/pullprogress_test.go were run but not read line by line

Task cca5550b-f2fa-4bd8-9ec6-f8d30e100441 on ci-16gb-fsn1-1, model claude-opus-5, effort medium.

@cmainas
cmainas marked this pull request as ready for review October 22, 2026 14:50
@cmainas
cmainas requested a review from ananos October 22, 2026 14:51

@ananos ananos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @cmainas, looks great!

I ran it on a Linux box against containerd 2.3.5 under rootlesskit. go test -race ./... passes with the daemon tests on. hull pull works for a short Hub name, an arm64 image on an amd64 host and a ghcr image, and ctr sees the same names. A bad --platform, a platform missing from the index, a missing tag and a missing repo each fail with one clear error.

Two small things: the "Depends on the hull images PR" line in the description is stale now that #124 is in, and this conflicts with #128 in main_linux.go and docs/build.md, so whichever lands second needs a quick rebase.

@cmainas
cmainas merged commit 80cee54 into main Oct 23, 2026
14 checks passed
@cmainas
cmainas deleted the feat/images_pull branch October 23, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants