Skip to content

Make Docker / OCI a format plugin over a host image store - #462

Merged
schmitch merged 4 commits into
mainfrom
claude/447-docker-plugin
Oct 24, 2026
Merged

schmitch merged 4 commits into
mainfrom
claude/447-docker-plugin

Conversation

@schmitch

@schmitch schmitch commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Fixes #447

Part of #442. #456 (#443), #458 (#444) and #461 (#445) have merged, and this PR targets main. main is merged in, npm (#463 and #465) included.

What changes

Docker / OCI is a FormatPlugin with storage: Images, split the way raw was in #431: the format crate speaks the protocol, and the host keeps the store.

The contract (forge-plugin-api, new images module, images formats only):

  • Storage::Images and IMAGES_MOUNT (/v2). The descriptor checks require an images format to mount at /v2 and allow only one images format per server. docker is no longer a reserved id: Docker is a built-in plugin.

  • image_path(method, path): the format splits the path below /v2 into the name and the endpoint, and says what the request needs (read, write, delete). The host resolves and authorizes with that, then calls handle with the endpoint as the path and the resolved image as Request::image (next to npm's Request::body, which an images format never gets).

  • Image queries:

    • reads: Manifest, Blob, Tags, Referrers;
    • changes, each alone in its round: StartUpload (with a cross-repository mount), UploadStatus, AppendUpload, FinishUpload, CancelUpload, PutManifest, DeleteManifest, DeleteBlob.

    Every answer is a Result<_, ImageError>.

  • Actions ServeBlob and ServeManifest.

  • image_error: the format renders refusals and failures (typed ImageError) as OCI errors. The host adds its Bearer challenge.

  • parse_manifest, config_summary (an image's platform, a chart's metadata, merged into the summary) and artifact_files (a chart's README and values, over a blob the host lends).

  • Upstream operations: ImageName, ImageManifest, ImageBlob, ImageTags and ImageReferrers.

    • Fetch gains a token scope, next to the accept npm added; Fetch::new and accepting build them.
    • FetchResult gains the status and link headers.
    • The host sends these fetches to the remote's origin only, and answers a 401 Bearer challenge with a token from the upstream's token service.
    • testing::converse_answered runs such conversations against canned answers.

The format (crates/formats/docker): DESCRIPTOR (docker, mount /v2, all three kinds, Storage::Images), plus:

  • protocol.rs: every endpoint's handle and render, with the response headers;
  • error.rs: OCI error codes, bodies and statuses;
  • upstream.rs: upstream URLs, Docker Hub's library/ names, tag pages through Link, referrers.

It keeps the path, name, reference, digest, manifest and chart code it already held. It implements reserved_prefix, so a group's reserved prefixes are image-name prefixes (acme/), and its upstream_token is None: the image store answers an upstream's Bearer challenge itself. It now depends on forge-plugin-api (plus serde, url and flate2) and nothing of the host.

The image store (crates/server/src/images, moved from the format crate) holds:

  • the token service, authentication, and name resolution by path or by host;
  • authorization: RBAC, selectors, token scopes, a group's writable member;
  • uploads, mounts and manifest commits, with immutable tags and the trusted-publishing ledger;
  • pulls through hosted, proxy and group repositories, with proxy caching and the upstream token exchange;
  • GC and the Nexus import.

dispatch.rs answers /v2/, /v2/token and /v2/_catalog itself. It runs every other request as a conversation with the format, and keeps only an allowlist of the response headers the format sets (location and link only under /v2/).

Host checks by storage, not by id. DOCKER_FORMAT and Repository::is_docker() are gone; checks ask FormatRegistry::stores_images or AppState::storage. This covers:

  • admin repositories, retention, proxy-cache invalidation and imports. A group's reserved prefixes are now always the format's reserved_prefix, Docker's included, so normalize_prefixes lost its Docker flag. The Docker options (host, immutable tags, a group's writable member) apply to a repository whose format stores images, and the writable member must be a hosted repository of that format, as npm's groups now have;
  • browse, browse images and the chart endpoint;
  • the retention worker and the upload limits (Config::default_upload_limit(Storage)).

GET /admin/formats lists Docker from its descriptor (storage: images, now with its version and description). The registry's routes come from the images plugin's mount. oci_dao::find_repository_by_host takes the format id. The platform-only summary queries gave way to config_media_type and complete_config_summary; .sqlx is regenerated.

The only "docker" left in host code outside tests is the Nexus importer's list of formats it copies (nexus_import::FORMATS). It is Nexus's vocabulary: it is matched against the Nexus source repository's format. Which store an import writes to is decided by storage.

npm. npm's upstream answers the image operations with a failure, as NuGet's does. The plugin dispatcher (/npm, /v3, /raw) refuses image queries and actions as a plugin's misuse, and the registry refuses npm's new actions (change, read body, auth, forward) the same way.

Docs. docs/format-plugins.md has an Images formats section in place of Why Docker is not a plugin. docs/docker.md, AGENTS.md, README.md, DEVELOPMENT.md and crates/formats/README.md no longer call Docker native. Changelog: docs/changes/2.0.0/447.feature.md.

Notes for review

  • crates/server/tests/registry.rs changes in three lines, and its assertions don't change:
    • Two import paths: token::MAX_SCOPES and gc::sweep_once moved with the image store, so they are forge_server::images::{token, gc} now. tests/admin.rs gets the same path change for token.
    • The retention test built its RetentionWorker with NuGet as the format of every repository. The worker used to recognize Docker by its id; it now asks the repository's format whether it stores images, as it already did for files formats. So the test gets the server's resolver (RegistryResolver), the one workers.rs uses.
  • browse::list_repos still names no client URL for an images repository. Its images have their own pull names (/browse/images), so it doesn't show /v2/<repo>.
  • The chart page now reads the chart through the format. /browse/chart does it with parse_manifest (a manifest that names files is a chart), config_summary (metadata under the summary's chart key) and artifact_files.
  • No web UI change: the admin and browse APIs answer as before.

Testing

On the head with main merged, npm (#463) included:

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo xtask changelog --check, and cargo sqlx prepare --check --workspace against a database migrated to 0047 all pass.
  • .github/scripts/test-parallel.sh --workspace: all 108 test binaries pass, npm.rs included.
  • .github/scripts/oci-e2e.sh passes with CI's pinned crane v0.20.2, cosign v3.0.2 and Helm v4.3.1, a registry:2 upstream and BuildKit. It covers:
    • the OCI conformance suite: Pass, 974 passed and 4 skipped, the same counts as main (anonymous mount is skipped there too);
    • docker buildx multi-arch push and pull;
    • crane ls, manifest and copy, with mounted blobs;
    • a proxy of the upstream and a group;
    • Helm push, pull and template, through the proxy and the group;
    • cosign signatures and attestations as referrers.
  • .github/scripts/npm-e2e.sh passes locally with Node 22 and pnpm 10.28, not CI's Node 26 and pnpm 11.5. It covers web and legacy logins, publish, install, dist-tags, deprecation, unpublish, the group's scope guard and audits.
  • Before the npm merge, the first full run failed three tests, all fixed before the first push:
    • browse_repos_by_format_with_client_urls: Docker repositories got a /v2/<repo> URL;
    • multi_arch_index_and_referrers: a rendered response carried the host's Content-Type ahead of the format's;
    • the retention test, for the resolver described above.
  • After merging npm: name access levels in the docs, and test a content selector on a write grant #465 (docs and an npm test), fmt, clippy, the changelog check, the server's unit tests (213) and npm.rs (13) pass again.
  • Not run locally: the docker and image-smoke jobs. They run in CI now that this targets main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CxwRK8xYPsMDBiMkySxcHf

Docker / OCI implements FormatPlugin with storage Images, split as raw
was: crates/formats/docker owns the distribution-spec protocol (paths,
grammar, manifests, error answers, charts, response headers, upstream
URLs), and the image store, the token service, authorization, GC and
the Nexus import move to the server as crates/server/src/images.

The plugin contract gains images formats: Storage::Images and
IMAGES_MOUNT, image_path to split a request's name and endpoint, image
queries (manifests, blobs, tags, referrers, upload sessions, manifest
puts and deletes) answered as results, the ServeBlob and ServeManifest
actions, image_error, parse_manifest, config_summary and
artifact_files, and upstream operations whose fetches carry an Accept
header and a token scope, the host exchanging an upstream's Bearer
tokens.

Host checks ask for images storage instead of the docker id:
DOCKER_FORMAT and Repository::is_docker are gone. GET /admin/formats
lists Docker from its descriptor. docs/format-plugins.md gets an Images
formats section in place of "Why Docker is not a plugin".

Fixes #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CxwRK8xYPsMDBiMkySxcHf
…aude/447-docker-plugin

# Conflicts:
#	crates/formats/docker/src/push.rs
#	crates/formats/docker/src/service.rs
#	crates/server/src/images/import.rs
…aude/447-docker-plugin

# Conflicts:
#	DEVELOPMENT.md
#	README.md
#	crates/core/tests/proxy_read.rs
#	crates/formats/README.md
#	crates/formats/nuget/src/upstream.rs
#	crates/formats/nuget/tests/upstream.rs
#	crates/plugin-api/src/types.rs
#	crates/server/Cargo.toml
#	crates/server/src/admin/repositories.rs
#	crates/server/src/plugins.rs
#	crates/server/src/state.rs
#	docs/format-plugins.md
Base automatically changed from claude/445-docker-crate to main October 24, 2026 15:45
# Conflicts:
#	crates/server/src/state.rs
#	docs/format-plugins.md

Copy link
Copy Markdown
Member Author

npm-e2e failed on d98f2e3 at "the upstream served the tarball more than once", but the upstream served each tarball once. The mock upstream registry in npm-e2e.sh logs from concurrent handler threads with print(), so the two tarball fetches of one install ran together on a single log line (…e2e-dep-2.0.0.tgz auth=FalseGET /e2e-public/-/e2e-public-1.1.0.tgz auth=False). The check counts lines that start with GET /e2e-public/…, found none, and failed.

That's a race in the test harness, not in this PR's code, and it can hit any PR. #469 fixes it on its own, per AGENTS.md: each line is written whole, under a lock. I'm re-running the failed job once.


Generated by Claude Code

@schmitch
schmitch added this pull request to the merge queue Oct 24, 2026
Merged via the queue into main with commit 1b7f7e8 Oct 24, 2026
35 of 36 checks passed
@schmitch
schmitch deleted the claude/447-docker-plugin branch October 24, 2026 16:10
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.

Docker / OCI as a FormatPlugin: an image store in the plugin contract

2 participants