+
+ )
+}
+
+// attestationLabel names a child that is not a platform. Every buildkit
+// multi-platform build attaches one, and it declares a platform of unknown/unknown,
+// so labelling it is the difference between provenance and an apparent broken build.
+function attestationLabel(child: DockerChild): string {
+ if (!child.referenceType) return child.platform || 'unknown'
+ if (child.referenceType === 'attestation-manifest') return 'attestation'
+ return child.referenceType
+}
+
+// Platforms is the part a plain file listing can never show: an index holds no
+// layers of its own, only one child manifest per platform.
+function Platforms({
+ platforms,
+ onSelect,
+}: {
+ platforms: DockerChild[]
+ onSelect: (digest: string) => void
+}) {
+ return (
+
+ {platforms.some((child) => !child.indexed)
+ ? 'A platform without a layer count has not been indexed here yet, which is normal while an index is still being pushed.'
+ : 'Open a platform to see its layers and the configuration it was built with. An index holds neither of its own.'}
+
+
+ )
+}
+
+// A foreign layer's source comes from a pushed manifest, so it is not necessarily a
+// URL that parses. The raw value is better than a crash.
+function sourceHost(urls: string[]): string {
+ const [first] = urls
+ if (!first) return 'elsewhere'
+ try {
+ return new URL(first).host
+ } catch {
+ return first
+ }
+}
+
+function LayerNote({ layer }: { layer: DockerLayer }) {
+ if (layer.foreign) {
+ return (
+
+ fetched from {sourceHost(layer.urls)}
+
+ )
+ }
+ if (!layer.stored) {
+ return (
+
+ missing
+
+ )
+ }
+ if (layer.sharedWith > 0) {
+ return (
+
+ shared with {plural(layer.sharedWith, 'image')}
+
+ )
+ }
+ return null
+}
+
+function LayerTable({ manifest }: { manifest: DockerManifest }) {
+ const shared = manifest.layers.filter((layer) => layer.sharedWith > 0)
+ const unique = manifest.layers
+ .filter((layer) => layer.sharedWith === 0 && !layer.foreign)
+ .reduce((total, layer) => total + layer.size, 0)
+
+ return (
+
+
+
Layers
+ {shared.length > 0 ? (
+
+ {formatBytes(unique)} of {formatBytes(manifest.totalSize)} is unique to this
+ tag, so deleting it reclaims only that much
+
+ The config blob for this image is not stored here, so its build settings cannot
+ be read. That is normal in a repository that was migrated rather than pushed.
+
+ )
+ }
+
+ const labels = Object.entries(manifest.labels)
+ const declared =
+ config.entrypoint.length +
+ config.cmd.length +
+ config.env.length +
+ config.exposedPorts.length +
+ labels.length +
+ (config.workingDir ? 1 : 0) +
+ (config.user ? 1 : 0)
+
+ // An image built by "docker import", or any scratch image, declares none of this.
+ // Saying so beats rendering an empty card.
+ if (declared === 0) {
+ return (
+
+
Configuration
+
+ This image declares no entrypoint, command, environment or labels.
+
+ )
+}
+
+// History is longer than the layer list, because an instruction that only changes
+// metadata produces an entry with no filesystem layer behind it.
+function History({ manifest }: { manifest: DockerManifest }) {
+ const history = manifest.config?.history ?? []
+ if (history.length === 0) return null
+
+ return (
+
+ )
+}
diff --git a/internal/frontend/src/components/snippets/docker.tsx b/internal/frontend/src/components/snippets/docker.tsx
new file mode 100644
index 0000000..95aff53
--- /dev/null
+++ b/internal/frontend/src/components/snippets/docker.tsx
@@ -0,0 +1,113 @@
+import { CodeBlock } from '@/components/copy-button'
+import type { RepositoryDetail } from '@/lib/api'
+
+// A Docker client builds its own URLs from the image reference, so the registry is
+// the bare host: no scheme and no path. The repository name is the first segment of
+// the image instead, which is how one host serves several repositories on one port.
+function registryHost(): string {
+ return window.location.host
+}
+
+function imageReference(repository: string, image: string): string {
+ return `${registryHost()}/${repository}/${image}`
+}
+
+function LoginBlock() {
+ return (
+
+ )
+}
+
+export function DockerUsage({ repository }: { repository: RepositoryDetail }) {
+ const host = registryHost()
+ const isProxy = repository.type === 'proxy'
+ const isPublic = repository.visibility === 'public'
+ const example = imageReference(repository.name, 'your-image')
+
+ return (
+
+
+
Registry
+
+ {isProxy
+ ? `This repository is read-only. Pull through it and every image is fetched from the remote once, then served from here. The repository name is the first segment of the image, so images resolve under ${host}/${repository.name}/.`
+ : `Images live under ${host}/${repository.name}/. The repository name is part of the image reference, which is how one host serves several repositories without a port or a hostname of its own.`}
+
{totals.copied} files copied, {totals.present} already present
{totals.failed > 0 ? `, ${totals.failed} failed` : ''}
+ {totals.untranslatable > 0
+ ? `, ${totals.untranslatable} paths this server has no place for`
+ : ''}
{status.current ? `, currently slurping ${status.current}...` : ''}
)
@@ -321,6 +329,157 @@ function statusVariant(state: MigrationStatus['state']) {
return 'neutral' as const
}
+// A docker repository is the one format that cannot reclaim space on delete: a tag's
+// layers are shared, so removing one leaves them behind on purpose. This is where
+// that decision gets revisited.
+function DockerSweepCard() {
+ const client = useQueryClient()
+ const [report, setReport] = useState(null)
+ const [swept, setSwept] = useState(false)
+ const [reindexed, setReindexed] = useState(null)
+
+ const repositories = useQuery({
+ queryKey: ['repositories'],
+ queryFn: () => api<{ repositories: Array<{ format: RepositoryFormat }> }>('/repositories'),
+ })
+
+ const preview = useMutation({
+ mutationFn: () => api('/admin/docker/sweep'),
+ onSuccess: (result) => {
+ setReport(result)
+ setSwept(false)
+ },
+ })
+
+ // Rebuilding reads every stored manifest afresh. It repairs a migration that was
+ // interrupted, or one run before this server knew how to index, without recopying.
+ const reindex = useMutation({
+ mutationFn: () => post('/admin/docker/reindex'),
+ onSuccess: (result) => setReindexed(result),
+ })
+
+ const sweep = useMutation({
+ mutationFn: () => post('/admin/docker/sweep'),
+ onSuccess: (result) => {
+ setReport(result)
+ setSwept(true)
+ client.invalidateQueries({ queryKey: ['repositories'] })
+ },
+ })
+
+ const hasDocker = (repositories.data?.repositories ?? []).some(
+ (entry) => entry.format === 'docker',
+ )
+ if (repositories.isLoading || !hasDocker) return null
+
+ const entries = (report?.repositories ?? []).filter(
+ (entry) => entry.blobs > 0 || entry.manifests > 0,
+ )
+ const error = (preview.error ?? sweep.error ?? reindex.error) as ApiError | null
+
+ return (
+
+
+ Docker housekeeping
+
+ Reclaiming removes layers and untagged manifests no tag can reach any more. A
+ layer shared with another tag is kept, which is why deleting a tag reclaims less
+ than its size; it also runs on its own every few hours. Rebuilding metadata
+ re-reads every stored manifest, which repairs a migration that stopped halfway.
+
+
+
+
+
+ )
+}
+
export function AdminMaintenanceRoute() {
const { user } = useSession()
if (user?.role !== 'admin') return
@@ -333,6 +492,7 @@ export function AdminMaintenanceRoute() {
description="One-off operations on this instance."
/>
+
>
)
}
diff --git a/internal/frontend/src/routes/admin-repositories.tsx b/internal/frontend/src/routes/admin-repositories.tsx
index 46b9f3f..a3a3d54 100644
--- a/internal/frontend/src/routes/admin-repositories.tsx
+++ b/internal/frontend/src/routes/admin-repositories.tsx
@@ -206,6 +206,7 @@ function CreateRepositoryDialog() {
const isGroup = type === 'group'
const isNPM = format === 'npm'
const isP2 = format === 'p2'
+ const isDocker = format === 'docker'
const existing = useQuery({
queryKey: ['repositories'],
@@ -267,7 +268,9 @@ function CreateRepositoryDialog() {
New repository
- {isNPM ? 'npm registry' : 'Maven 2 layout'}, served at /repository/{name || 'name'}
+ {isDocker
+ ? `Docker registry, pulled as ${window.location.host}/${name || 'name'}/`
+ : `${isNPM ? 'npm registry' : isP2 ? 'p2 update site' : 'Maven 2 layout'}, served at /repository/${name || 'name'}`}
@@ -289,6 +292,7 @@ function CreateRepositoryDialog() {
+
@@ -319,7 +323,9 @@ function CreateRepositoryDialog() {
? 'The registry base URL this repository mirrors.'
: isP2
? 'The host root to mirror, without a path. Update sites point at children above their own directory, which only resolves from the root.'
- : 'The Maven 2 base URL this repository mirrors.'
+ : isDocker
+ ? 'The registry to pull through. Docker Hub needs no path.'
+ : 'The Maven 2 base URL this repository mirrors.'
}
>
@@ -627,9 +635,13 @@ function SettingsDialog({ repository }: { repository: Repository }) {
className="mt-0.5"
/>
- Allow overwriting existing releases
+ {repository.format === 'docker'
+ ? 'Allow tags to be moved to another image'
+ : 'Allow overwriting existing releases'}
- Off by default so a published version can never change underneath a build.
+ {repository.format === 'docker'
+ ? 'Normal Docker practice, since a tag like latest is meant to move. Turn it off to make every tag here permanent.'
+ : 'Off by default so a published version can never change underneath a build.'}
diff --git a/internal/frontend/src/routes/artifact.tsx b/internal/frontend/src/routes/artifact.tsx
index b99344c..c2b3c14 100644
--- a/internal/frontend/src/routes/artifact.tsx
+++ b/internal/frontend/src/routes/artifact.tsx
@@ -11,14 +11,17 @@ import { CopyButton } from '@/components/copy-button'
import { DeleteArtifactDialog } from '@/components/delete-artifact'
import { MavenDependency, MavenSetup } from '@/components/snippets/maven'
import { NPMDependency, NPMSetup } from '@/components/snippets/npm'
+import { DockerDependency, DockerUsage } from '@/components/snippets/docker'
+import { DockerManifestView } from '@/components/docker-manifest'
import {
api,
type ArtifactDetail,
type BrowseEntry,
+ type DockerManifest,
type RepositoryDetail,
type RepositoryFormat,
} from '@/lib/api'
-import { coordinateLabel } from '@/lib/coordinates'
+import { coordinateLabel, packageLabel } from '@/lib/coordinates'
import { cn, formatBytes, formatRelative, plural } from '@/lib/utils'
function Files({
@@ -97,18 +100,34 @@ function Snippets({
namespace,
name,
version,
+ digest,
}: {
format: RepositoryFormat
repository: string
namespace: string
name: string
version: string
+ digest?: string
}) {
const { data } = useQuery({
queryKey: ['repository', repository],
queryFn: () => api(`/repositories/${repository}`),
})
+ if (format === 'docker') {
+ return (
+
+
+ {data ? : null}
+
+ )
+ }
+
if (format === 'npm') {
return (
@@ -150,16 +169,33 @@ export function ArtifactRoute() {
enabled: name.length > 0,
})
+ // The pull snippets want the manifest digest, and the manifest tab wants the whole
+ // record. They share a query key, so this costs one request rather than two.
+ const reference = selected ?? data?.versions[0]?.version ?? ''
+ const manifestQuery = new URLSearchParams({ namespace, name, reference })
+ const manifest = useQuery({
+ queryKey: ['docker-manifest', repository, namespace, name, reference],
+ queryFn: () =>
+ api(`/repositories/${repository}/docker/manifests?${manifestQuery}`),
+ enabled: data?.format === 'docker' && reference.length > 0,
+ })
+
if (!name) return
if (isLoading) return
if (error) return
if (!data) return null
- const version = selected ?? data.versions[0]?.version ?? ''
+ const version = reference
const format = data.format
- const npm = format === 'npm'
+ const docker = format === 'docker'
const coordinates = coordinateLabel(format, namespace, name, version)
+ // npm and docker both write the namespace into the name, so the heading is one
+ // line; Maven and p2 keep it above. The separator before a version differs again.
+ const inlineNamespace = format === 'npm' || docker
+ const namespacePrefix = format === 'npm' ? `@${namespace}/` : `${namespace}/`
+ const versionSeparator = format === 'npm' ? '@' : ':'
+
return (
<>
@@ -172,9 +208,9 @@ export function ArtifactRoute() {
diff --git a/internal/frontend/src/routes/repositories.tsx b/internal/frontend/src/routes/repositories.tsx
index 2f74d49..44f8bf1 100644
--- a/internal/frontend/src/routes/repositories.tsx
+++ b/internal/frontend/src/routes/repositories.tsx
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Globe, Lock } from 'lucide-react'
import { PolicyBadge } from '@/components/ui/badge'
+import { FormatIcon } from '@/components/ui/format-icon'
import { Card } from '@/components/ui/card'
import { EmptyState, ErrorBlock, LoadingBlock, PageHeading } from '@/components/ui/feedback'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -32,8 +33,9 @@ function RepositoryTable({ repositories }: { repositories: Repository[] }) {
+
{repository.name}
{repository.description ? (
diff --git a/internal/frontend/src/routes/repository.tsx b/internal/frontend/src/routes/repository.tsx
index 1255091..626575c 100644
--- a/internal/frontend/src/routes/repository.tsx
+++ b/internal/frontend/src/routes/repository.tsx
@@ -11,6 +11,7 @@ import { Coordinate, CopyButton } from '@/components/copy-button'
import { MavenUsage } from '@/components/snippets/maven'
import { NPMUsage } from '@/components/snippets/npm'
import { P2Usage } from '@/components/snippets/p2'
+import { DockerUsage } from '@/components/snippets/docker'
import {
api,
type ArtifactSummary,
@@ -88,9 +89,11 @@ function Browser({
)
@@ -191,16 +194,22 @@ function Overview({
if (artifacts.length === 0) {
return isProxy ? (
) : (
)
@@ -211,9 +220,9 @@ function Overview({
- Artifact
+ {format === 'docker' ? 'Image' : 'Artifact'}Latest
- Versions
+ {format === 'docker' ? 'Tags' : 'Versions'}Updated
@@ -251,9 +260,23 @@ function Overview({
function Usage({ repository }: { repository: RepositoryDetail }) {
if (repository.format === 'npm') return
if (repository.format === 'p2') return
+ if (repository.format === 'docker') return
return
}
+// dockerOrDefault keeps the stat labels honest per format without three nested
+// ternaries at each call site.
+function dockerOrDefault(
+ isDocker: boolean,
+ isProxy: boolean,
+ docker: string,
+ proxy: string,
+ hosted: string,
+): string {
+ if (isDocker) return docker
+ return isProxy ? proxy : hosted
+}
+
export function RepositoryRoute() {
const params = useParams()
const name = params.name!
@@ -268,7 +291,12 @@ export function RepositoryRoute() {
if (error) return
if (!data) return null
- const endpoint = `${window.location.origin}/repository/${data.name}`
+ const docker = data.format === 'docker'
+ // A Docker client builds its own URLs from the image reference, so the thing to
+ // copy is the host and repository prefix, not the path the other formats serve on.
+ const endpoint = docker
+ ? `${window.location.host}/${data.name}/`
+ : `${window.location.origin}/repository/${data.name}`
return (
<>
@@ -300,6 +328,17 @@ export function RepositoryRoute() {
+ {docker ? (
+
+ Images are addressed as{' '}
+
+ {window.location.host}/{data.name}/<image>:<tag>
+
+ . The repository name is part of the reference, which is how one host serves
+ several repositories without a port or a hostname of its own.
+
+ ) : null}
+
{data.type === 'proxy' ? (
Mirrors {data.remoteUrl}. Artifacts are
@@ -311,11 +350,14 @@ export function RepositoryRoute() {
) : null}
-
+
diff --git a/internal/migrate/docker.go b/internal/migrate/docker.go
new file mode 100644
index 0000000..14a6caa
--- /dev/null
+++ b/internal/migrate/docker.go
@@ -0,0 +1,66 @@
+package migrate
+
+import (
+ "strings"
+
+ "arca/internal/docker"
+)
+
+// Nexus lays a docker repository out as the registry API addresses it, under a v2
+// prefix, and stores blobs once on a shared path rather than per image. Verified
+// against a Nexus 3.70 instance, which serves only the shared shape; the per-image one
+// is kept because other versions may differ and it costs a case.
+//
+// v2/-/blobs/sha256: the shared blob store
+// v2//blobs/sha256: a per-image blob
+// v2//manifests/sha256: a manifest by digest
+// v2//manifests/ a tag, which only the components walk lists
+const (
+ nexusRoot = "v2/"
+ nexusSharedBlobs = "-"
+ nexusBlobs = "blobs"
+ nexusManifests = "manifests"
+)
+
+// DockerPath maps a Nexus docker path onto this server's layout. It reports false for
+// anything it does not recognise, so an unexpected shape is skipped and counted rather
+// than stored somewhere nothing will read it back from.
+func DockerPath(path string) (string, bool) {
+ rest, found := strings.CutPrefix(strings.TrimPrefix(path, "/"), nexusRoot)
+ if !found {
+ return "", false
+ }
+
+ cut := strings.LastIndex(rest, "/"+nexusBlobs+"/")
+ if cut >= 0 {
+ // Every blob lands in one store regardless of which image Nexus filed it
+ // under, because a digest names the same bytes either way.
+ digest, ok := docker.ParseDigest(rest[cut+len(nexusBlobs)+2:])
+ if !ok {
+ return "", false
+ }
+ image := rest[:cut]
+ if image != nexusSharedBlobs && !docker.IsValidName(image) {
+ return "", false
+ }
+ return docker.BlobPath(digest), true
+ }
+
+ cut = strings.LastIndex(rest, "/"+nexusManifests+"/")
+ if cut < 0 {
+ return "", false
+ }
+
+ image, reference := rest[:cut], rest[cut+len(nexusManifests)+2:]
+ if !docker.IsValidName(image) {
+ return "", false
+ }
+
+ if digest, ok := docker.ParseDigest(reference); ok {
+ return docker.ManifestPath(image, digest), true
+ }
+ if docker.IsValidTag(reference) {
+ return docker.TagPath(image, reference), true
+ }
+ return "", false
+}
diff --git a/internal/migrate/docker_test.go b/internal/migrate/docker_test.go
new file mode 100644
index 0000000..91e6928
--- /dev/null
+++ b/internal/migrate/docker_test.go
@@ -0,0 +1,91 @@
+package migrate
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDockerPath(t *testing.T) {
+ hex := strings.Repeat("ab", 32)
+ digest := "sha256:" + hex
+
+ cases := []struct {
+ name string
+ path string
+ want string
+ ok bool
+ }{
+ // The shared blob store is what a real Nexus 3.70 uses, and the only shape its
+ // asset listing showed.
+ {name: "a shared blob", path: "v2/-/blobs/" + digest, want: "_blobs/sha256/" + hex, ok: true},
+ {name: "a leading slash is tolerated", path: "/v2/-/blobs/" + digest, want: "_blobs/sha256/" + hex, ok: true},
+
+ // Kept because other versions may file blobs per image, and a digest names the
+ // same bytes either way.
+ {name: "a per-image blob", path: "v2/nginx/blobs/" + digest, want: "_blobs/sha256/" + hex, ok: true},
+ {name: "a per-image blob under a namespace", path: "v2/team/api/blobs/" + digest, want: "_blobs/sha256/" + hex, ok: true},
+
+ {
+ name: "a manifest by digest", path: "v2/team/api/manifests/" + digest,
+ want: "team/api/_manifests/sha256/" + hex, ok: true,
+ },
+ {
+ name: "a manifest by tag", path: "v2/team/api/manifests/1.0",
+ want: "team/api/1.0/manifest.json", ok: true,
+ },
+ {
+ name: "a deep image name", path: "v2/a/b/c/manifests/latest",
+ want: "a/b/c/latest/manifest.json", ok: true,
+ },
+
+ {name: "nothing", path: ""},
+ {name: "no v2 prefix", path: "team/api/manifests/1.0"},
+ {name: "the tag list", path: "v2/team/api/tags/list"},
+ {name: "an unknown verb", path: "v2/team/api/layers/1"},
+ {name: "a nameless manifest", path: "v2/manifests/1.0"},
+ {name: "an uppercase image", path: "v2/TEAM/api/manifests/1.0"},
+ {name: "an unusable digest", path: "v2/-/blobs/sha256:short"},
+ {name: "an unusable algorithm", path: "v2/-/blobs/md5:" + strings.Repeat("ab", 16)},
+ {name: "an invalid tag", path: "v2/team/api/manifests/.hidden"},
+ {name: "a traversal attempt", path: "v2/team/api/manifests/sha256:../../etc/passwd"},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ got, ok := DockerPath(testCase.path)
+ if ok != testCase.ok {
+ t.Fatalf("DockerPath(%q) ok = %v, want %v", testCase.path, ok, testCase.ok)
+ }
+ if ok && got != testCase.want {
+ t.Fatalf("DockerPath(%q) = %q, want %q", testCase.path, got, testCase.want)
+ }
+ })
+ }
+}
+
+// Only docker rewrites paths. Every other format stores what Nexus stored, and quietly
+// changing one of those would move artifacts out from under the builds that resolve them.
+func TestTranslatePathLeavesOtherFormatsAlone(t *testing.T) {
+ cases := []struct {
+ format string
+ path string
+ }{
+ {"maven2", "com/example/app/1.0/app-1.0.jar"},
+ {"npm", "@scope/package/1.0.0/package-1.0.0.tgz"},
+ {"p2", "logging/2.0/plugins/slf4j.api_2.0.16.jar"},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.format, func(t *testing.T) {
+ decision := Decision{Format: testCase.format}
+
+ got, ok := decision.TranslatePath(testCase.path)
+ if !ok || got != testCase.path {
+ t.Fatalf("TranslatePath(%q) = (%q, %v), want it unchanged", testCase.path, got, ok)
+ }
+ if decision.CopyComponents() {
+ t.Fatal("a non-docker format asked for the component walk")
+ }
+ })
+ }
+}
diff --git a/internal/migrate/plan.go b/internal/migrate/plan.go
index ad1bd78..e519dba 100644
--- a/internal/migrate/plan.go
+++ b/internal/migrate/plan.go
@@ -33,6 +33,24 @@ type Decision struct {
Reason string
}
+// TranslatePath maps a source path onto the layout this server stores. Most formats
+// store what Nexus stored, so the default is the path unchanged; docker addresses its
+// content entirely differently at both ends.
+func (d Decision) TranslatePath(path string) (string, bool) {
+ if d.Format == format.Docker {
+ return DockerPath(path)
+ }
+ return path, true
+}
+
+// CopyComponents reports whether the source's component listing has to be walked as
+// well as its assets. Only docker needs it, and it needs it badly: the asset list names
+// no tags at all, so assets alone copy every byte of every image and leave none of them
+// pullable.
+func (d Decision) CopyComponents() bool {
+ return d.CopyAssets && d.Format == format.Docker
+}
+
func (d Decision) Name() string { return d.Target }
func (d Decision) SourceName() string { return d.Source.Name }
@@ -94,10 +112,7 @@ func decide(repository nexus.Repository, options Options) Decision {
// Membership is not migrated: arca groups exist only for p2, and which
// members a group should carry is a decision worth making by hand.
if repository.IsGroup() {
- decision.Reason = fmt.Sprintf(
- "groups are not migrated; recreate it as a p2 group, or point clients at the members directly (%s)",
- strings.Join(repository.Members(), ", "),
- )
+ decision.Reason = groupReason(repository)
return decision
}
@@ -131,12 +146,29 @@ func decide(repository nexus.Repository, options Options) Decision {
return decision
}
+// groupReason explains a skipped group. The member list is often unavailable: the
+// repository endpoint returns empty attributes on some Nexus versions, so claiming a
+// group has no members would be worse than saying they could not be read.
+func groupReason(repository nexus.Repository) string {
+ members := repository.Members()
+ if len(members) == 0 {
+ return "groups are not migrated, and this Nexus did not report which repositories this one " +
+ "contains; recreate it by hand, or point clients at the members directly"
+ }
+ return fmt.Sprintf(
+ "groups are not migrated; recreate it as a p2 group, or point clients at the members directly (%s)",
+ strings.Join(members, ", "),
+ )
+}
+
func formatFor(nexusFormat string, options Options) (string, bool) {
switch nexusFormat {
case "maven2":
return format.Maven2, true
case "npm":
return format.NPM, true
+ case "docker":
+ return format.Docker, true
case "raw":
return options.rawFormat(), true
default:
@@ -146,7 +178,15 @@ func formatFor(nexusFormat string, options Options) (string, bool) {
// policyFor carries a Maven repository's version policy across. Nexus has no
// equivalent for the other formats, which take arca's default.
+//
+// Docker is the exception: its tags carry arbitrary suffixes, and real ones like
+// 0.1.9-swaggerui-staging are neither a release nor a prerelease by any rule worth
+// writing, so a mixed policy is the only honest choice.
func policyFor(repository nexus.Repository) string {
+ if repository.Format == "docker" {
+ return models.PolicyMixed
+ }
+
switch strings.ToUpper(repository.VersionPolicy()) {
case "SNAPSHOT":
return models.PolicySnapshot
diff --git a/internal/migrate/plan_test.go b/internal/migrate/plan_test.go
index 8a52209..0adc187 100644
--- a/internal/migrate/plan_test.go
+++ b/internal/migrate/plan_test.go
@@ -55,8 +55,12 @@ func TestPlanDecides(t *testing.T) {
action: ActionSkip, reason: "maven-central, maven-snapshots",
},
{
- name: "an unsupported format is skipped", source: repository("docker-hosted", "docker", "hosted"),
- action: ActionSkip, reason: "no docker format",
+ name: "an unsupported format is skipped", source: repository("gems", "rubygems", "hosted"),
+ action: ActionSkip, reason: "no rubygems format",
+ },
+ {
+ name: "a docker repository carries across", source: repository("docker", "docker", "hosted"),
+ action: ActionCreate, wantFormat: "docker", wantType: "hosted", copies: true,
},
{
name: "a proxy without an upstream is skipped", source: orphan,
diff --git a/internal/migrate/run.go b/internal/migrate/run.go
index fc0e45d..f1b6408 100644
--- a/internal/migrate/run.go
+++ b/internal/migrate/run.go
@@ -22,6 +22,10 @@ type Destination interface {
CreateRepository(ctx context.Context, decision Decision, visibility string) error
HasAsset(ctx context.Context, repository, path string) (bool, error)
PutAsset(ctx context.Context, repository, path string, size int64, body io.Reader) error
+ // Settled runs once a repository's content is all in place, for a format whose
+ // metadata cannot be built as the files arrive. Docker needs it: a manifest
+ // references blobs that the copy order gives no guarantee of having seen yet.
+ Settled(ctx context.Context, decision Decision) error
}
// Result records what happened to one repository.
@@ -32,7 +36,10 @@ type Result struct {
Copied int
Skipped int
Failed int
- Errors []string
+ // Untranslatable counts source paths this server has no place for. Reporting them
+ // is what stops a copy claiming to be complete when it silently dropped content.
+ Untranslatable int
+ Errors []string
}
type Reporter interface {
@@ -95,6 +102,17 @@ func (r *Runner) applyOne(ctx context.Context, decision Decision) Result {
}
r.copyAssets(ctx, decision, &result)
+ if decision.CopyComponents() {
+ r.copyComponents(ctx, decision, &result)
+ }
+
+ // Settling comes last for the same reason it exists: only now is every blob a
+ // manifest might reference actually present.
+ if err := r.Target.Settled(ctx, decision); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, "building metadata: "+err.Error())
+ }
+
return result
}
@@ -114,24 +132,12 @@ func (r *Runner) copyAssets(ctx context.Context, decision Decision, result *Resu
go func() {
defer workers.Done()
for asset := range queue {
- copied, err := r.copyAsset(ctx, decision.Name(), asset)
+ // The transfer happens outside the lock. Holding it across a
+ // download would serialise the pool this exists to parallelise.
+ copied, err := r.copyAsset(ctx, decision, asset)
mutex.Lock()
- switch {
- case err != nil:
- result.Failed++
- result.Errors = append(result.Errors, asset.Path+": "+err.Error())
- if r.Reporter != nil {
- r.Reporter.Problem(decision.Name(), asset.Path, err)
- }
- case copied:
- result.Copied++
- default:
- result.Skipped++
- }
- if r.Reporter != nil {
- r.Reporter.Progress(decision.Name(), result.Copied, result.Skipped, result.Failed)
- }
+ r.record(decision, asset, copied, err, result)
mutex.Unlock()
}
}()
@@ -152,10 +158,71 @@ func (r *Runner) copyAssets(ctx context.Context, decision Decision, result *Resu
}
}
+// copyComponents walks the source grouped by version. It exists for docker, whose tags
+// appear in no other listing, and it copies only the assets the asset walk could not
+// have seen.
+func (r *Runner) copyComponents(ctx context.Context, decision Decision, result *Result) {
+ err := r.Source.Components(ctx, decision.SourceName(), func(page []nexus.Component) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ for _, component := range page {
+ for _, asset := range component.Assets {
+ r.recordCopy(ctx, decision, asset, result)
+ }
+ }
+ return nil
+ })
+
+ if err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, err.Error())
+ }
+}
+
+// recordCopy transfers one asset and books the outcome. Only the components walk uses
+// it, which runs on one goroutine, so it needs no lock of its own.
+func (r *Runner) recordCopy(ctx context.Context, decision Decision, asset nexus.Asset, result *Result) {
+ copied, err := r.copyAsset(ctx, decision, asset)
+ r.record(decision, asset, copied, err, result)
+}
+
+// record books one outcome. Callers hold whatever lock the result needs.
+func (r *Runner) record(decision Decision, asset nexus.Asset, copied bool, err error, result *Result) {
+ switch {
+ case errors.Is(err, errUntranslatable):
+ result.Untranslatable++
+ case err != nil:
+ result.Failed++
+ result.Errors = append(result.Errors, asset.Path+": "+err.Error())
+ if r.Reporter != nil {
+ r.Reporter.Problem(decision.Name(), asset.Path, err)
+ }
+ case copied:
+ result.Copied++
+ default:
+ result.Skipped++
+ }
+
+ if r.Reporter != nil {
+ r.Reporter.Progress(decision.Name(), result.Copied, result.Skipped, result.Failed)
+ }
+}
+
+var errUntranslatable = errors.New("migrate: this server has no place for that path")
+
// copyAsset reports whether anything was transferred. An asset already in arca
// is left alone so a rerun costs one HEAD instead of a download.
-func (r *Runner) copyAsset(ctx context.Context, repository string, asset nexus.Asset) (bool, error) {
- present, err := r.Target.HasAsset(ctx, repository, asset.Path)
+func (r *Runner) copyAsset(ctx context.Context, decision Decision, asset nexus.Asset) (bool, error) {
+ path, ok := decision.TranslatePath(asset.Path)
+ if !ok {
+ return false, errUntranslatable
+ }
+
+ repository := decision.Name()
+
+ present, err := r.Target.HasAsset(ctx, repository, path)
if err != nil {
return false, err
}
@@ -169,7 +236,7 @@ func (r *Runner) copyAsset(ctx context.Context, repository string, asset nexus.A
}
defer body.Close()
- if err := r.Target.PutAsset(ctx, repository, asset.Path, asset.FileSize, body); err != nil {
+ if err := r.Target.PutAsset(ctx, repository, path, asset.FileSize, body); err != nil {
return false, err
}
return true, nil
diff --git a/internal/nexus/client.go b/internal/nexus/client.go
index 4c5493d..a849987 100644
--- a/internal/nexus/client.go
+++ b/internal/nexus/client.go
@@ -80,9 +80,23 @@ type Asset struct {
} `json:"checksum"`
}
-type assetPage struct {
- Items []Asset `json:"items"`
- ContinuationToken string `json:"continuationToken"`
+// Component is one versioned thing, with its assets nested. For docker this is the
+// only endpoint that names tags at all: the asset list reports manifests by digest and
+// nothing else, so a migration driven off assets alone copies every byte and produces
+// no tags.
+type Component struct {
+ ID string `json:"id"`
+ Group string `json:"group"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Repository string `json:"repository"`
+ Format string `json:"format"`
+ Assets []Asset `json:"assets"`
+}
+
+type page[T any] struct {
+ Items []T `json:"items"`
+ ContinuationToken string `json:"continuationToken"`
}
func (c *Client) get(ctx context.Context, path string, query url.Values) (*http.Response, error) {
@@ -135,6 +149,17 @@ func (c *Client) Repositories(ctx context.Context) ([]Repository, error) {
// Nexus pages with. The callback runs per page so a large repository never has
// to be held in memory at once.
func (c *Client) Assets(ctx context.Context, repository string, visit func([]Asset) error) error {
+ return walk(ctx, c, "/service/rest/v1/assets", repository, "assets", visit)
+}
+
+// Components walks the same repository grouped by version. Both walks are needed for
+// docker, whose two endpoints expose disjoint views: assets hold the blobs and the
+// digest-addressed manifests, components hold the tags.
+func (c *Client) Components(ctx context.Context, repository string, visit func([]Component) error) error {
+ return walk(ctx, c, "/service/rest/v1/components", repository, "components", visit)
+}
+
+func walk[T any](ctx context.Context, c *Client, path, repository, subject string, visit func([]T) error) error {
token := ""
for {
@@ -143,25 +168,25 @@ func (c *Client) Assets(ctx context.Context, repository string, visit func([]Ass
query.Set("continuationToken", token)
}
- response, err := c.get(ctx, "/service/rest/v1/assets", query)
+ response, err := c.get(ctx, path, query)
if err != nil {
return err
}
- var page assetPage
- err = json.NewDecoder(response.Body).Decode(&page)
+ var current page[T]
+ err = json.NewDecoder(response.Body).Decode(¤t)
response.Body.Close()
if err != nil {
- return fmt.Errorf("nexus: reading assets of %s: %w", repository, err)
+ return fmt.Errorf("nexus: reading %s of %s: %w", subject, repository, err)
}
- if err := visit(page.Items); err != nil {
+ if err := visit(current.Items); err != nil {
return err
}
- if page.ContinuationToken == "" {
+ if current.ContinuationToken == "" {
return nil
}
- token = page.ContinuationToken
+ token = current.ContinuationToken
}
}
diff --git a/internal/proxy/client.go b/internal/proxy/client.go
index 1e03143..52fe729 100644
--- a/internal/proxy/client.go
+++ b/internal/proxy/client.go
@@ -45,7 +45,8 @@ func (r *Response) Close() {
}
type Client struct {
- http *http.Client
+ http *http.Client
+ tokens *tokenCache
}
func NewClient(timeout time.Duration) *Client {
@@ -53,7 +54,13 @@ func NewClient(timeout time.Duration) *Client {
transport.MaxIdleConnsPerHost = 16
transport.ResponseHeaderTimeout = 30 * time.Second
- return &Client{http: &http.Client{Timeout: timeout, Transport: transport}}
+ // Redirects are followed, which container registries rely on: a blob GET is
+ // answered with a 307 to object storage. Go strips the Authorization header when
+ // a redirect crosses hosts, so the token never reaches the CDN.
+ return &Client{
+ http: &http.Client{Timeout: timeout, Transport: transport},
+ tokens: newTokenCache(),
+ }
}
func NormalizeRemoteURL(raw string) (string, error) {
@@ -80,6 +87,46 @@ func (c *Client) Fetch(ctx context.Context, remote Remote, path string, conditio
}
target.Path = strings.TrimSuffix(target.Path, "/") + "/" + path
+ response, err := c.send(ctx, remote, target, conditional, "")
+ if err != nil {
+ return nil, err
+ }
+
+ // A container registry answers an unauthenticated request with a challenge to a
+ // separate token service rather than accepting credentials itself. One retry with
+ // the issued token is the whole of that protocol.
+ if response.StatusCode == http.StatusUnauthorized {
+ answer, parseErr := parseChallenge(response.Header.Get("WWW-Authenticate"))
+ if parseErr == nil {
+ drain(response)
+
+ token, tokenErr := c.token(ctx, remote, answer)
+ if tokenErr != nil {
+ return nil, tokenErr
+ }
+ if response, err = c.send(ctx, remote, target, conditional, token); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ result := &Response{
+ Status: response.StatusCode,
+ ContentType: response.Header.Get("Content-Type"),
+ ETag: response.Header.Get("ETag"),
+ LastModified: response.Header.Get("Last-Modified"),
+ }
+
+ if response.StatusCode == http.StatusOK {
+ result.Body = response.Body
+ } else {
+ drain(response)
+ }
+
+ return result, nil
+}
+
+func (c *Client) send(ctx context.Context, remote Remote, target *url.URL, conditional Conditional, token string) (*http.Response, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
@@ -92,7 +139,10 @@ func (c *Client) Fetch(ctx context.Context, remote Remote, path string, conditio
request.Header.Set("User-Agent", userAgent)
request.Header.Set("Accept", accept)
- if remote.Username != "" || remote.Password != "" {
+ switch {
+ case token != "":
+ request.Header.Set("Authorization", "Bearer "+token)
+ case remote.Username != "" || remote.Password != "":
request.SetBasicAuth(remote.Username, remote.Password)
}
if conditional.ETag != "" {
@@ -106,20 +156,10 @@ func (c *Client) Fetch(ctx context.Context, remote Remote, path string, conditio
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", target.Redacted(), err)
}
+ return response, nil
+}
- result := &Response{
- Status: response.StatusCode,
- ContentType: response.Header.Get("Content-Type"),
- ETag: response.Header.Get("ETag"),
- LastModified: response.Header.Get("Last-Modified"),
- }
-
- if response.StatusCode == http.StatusOK {
- result.Body = response.Body
- } else {
- _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
- response.Body.Close()
- }
-
- return result, nil
+func drain(response *http.Response) {
+ _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
+ response.Body.Close()
}
diff --git a/internal/proxy/token.go b/internal/proxy/token.go
new file mode 100644
index 0000000..1d3945a
--- /dev/null
+++ b/internal/proxy/token.go
@@ -0,0 +1,214 @@
+package proxy
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Container registries do not accept credentials directly. They answer an
+// unauthenticated request with a Bearer challenge naming a separate token service,
+// and the client exchanges its credentials there for a short-lived token scoped to
+// one repository. Docker Hub is the common case: registry-1.docker.io challenges to
+// auth.docker.io.
+//
+// The exchange is what makes anonymous pulls work too: with no credentials the token
+// service still issues a token, it just carries fewer rights.
+
+const (
+ // tokenSkew expires a cached token early, so one is never presented in the
+ // moment it stops being valid.
+ tokenSkew = 30 * time.Second
+ // tokenLifetime is what a service that reports no expiry is assumed to grant.
+ tokenLifetime = 5 * time.Minute
+ maxTokenBytes = 1 << 20
+)
+
+var errNoChallenge = errors.New("proxy: the response carries no bearer challenge")
+
+type challenge struct {
+ Realm string
+ Service string
+ Scope string
+}
+
+// parseChallenge reads a WWW-Authenticate header. Only the Bearer scheme is handled:
+// a Basic challenge means the credentials were already sent and rejected, which is not
+// something a retry can fix.
+func parseChallenge(header string) (challenge, error) {
+ rest, found := cutPrefixFold(header, "bearer ")
+ if !found {
+ return challenge{}, errNoChallenge
+ }
+
+ parsed := challenge{}
+ for _, parameter := range splitParameters(rest) {
+ key, value, found := strings.Cut(parameter, "=")
+ if !found {
+ continue
+ }
+
+ value = strings.Trim(strings.TrimSpace(value), `"`)
+ switch strings.ToLower(strings.TrimSpace(key)) {
+ case "realm":
+ parsed.Realm = value
+ case "service":
+ parsed.Service = value
+ case "scope":
+ parsed.Scope = value
+ }
+ }
+
+ if parsed.Realm == "" {
+ return challenge{}, errNoChallenge
+ }
+ return parsed, nil
+}
+
+func cutPrefixFold(value, prefix string) (string, bool) {
+ if len(value) < len(prefix) || !strings.EqualFold(value[:len(prefix)], prefix) {
+ return "", false
+ }
+ return value[len(prefix):], true
+}
+
+// splitParameters splits on commas that are not inside a quoted value, because a
+// scope routinely contains one: "repository:a:pull,push".
+func splitParameters(value string) []string {
+ var parameters []string
+ quoted := false
+ start := 0
+
+ for index := 0; index < len(value); index++ {
+ switch value[index] {
+ case '"':
+ quoted = !quoted
+ case ',':
+ if !quoted {
+ parameters = append(parameters, value[start:index])
+ start = index + 1
+ }
+ }
+ }
+ return append(parameters, value[start:])
+}
+
+func (c challenge) key(remote Remote) string {
+ return remote.BaseURL + "|" + c.Realm + "|" + c.Service + "|" + c.Scope
+}
+
+type cachedToken struct {
+ token string
+ expires time.Time
+}
+
+type tokenCache struct {
+ mutex sync.Mutex
+ tokens map[string]cachedToken
+}
+
+func newTokenCache() *tokenCache { return &tokenCache{tokens: map[string]cachedToken{}} }
+
+func (t *tokenCache) get(key string) (string, bool) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+
+ entry, ok := t.tokens[key]
+ if !ok || time.Now().After(entry.expires) {
+ return "", false
+ }
+ return entry.token, true
+}
+
+func (t *tokenCache) put(key, token string, lifetime time.Duration) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+
+ t.tokens[key] = cachedToken{token: token, expires: time.Now().Add(lifetime - tokenSkew)}
+}
+
+type tokenResponse struct {
+ Token string `json:"token"`
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+func (t tokenResponse) value() string {
+ if t.Token != "" {
+ return t.Token
+ }
+ return t.AccessToken
+}
+
+func (t tokenResponse) lifetime() time.Duration {
+ if t.ExpiresIn <= 0 {
+ return tokenLifetime
+ }
+ return time.Duration(t.ExpiresIn) * time.Second
+}
+
+// token answers a challenge, from cache when it can. Credentials are only ever sent
+// to an HTTPS realm: the realm is named by the upstream's own response, so anything
+// less would let a compromised or impersonated registry collect them in the clear.
+func (c *Client) token(ctx context.Context, remote Remote, answer challenge) (string, error) {
+ key := answer.key(remote)
+ if cached, ok := c.tokens.get(key); ok {
+ return cached, nil
+ }
+
+ realm, err := url.Parse(answer.Realm)
+ if err != nil || realm.Host == "" {
+ return "", fmt.Errorf("proxy: %q is not a usable token realm", answer.Realm)
+ }
+ credentialled := remote.Username != "" || remote.Password != ""
+ if credentialled && realm.Scheme != "https" {
+ return "", fmt.Errorf("proxy: refusing to send credentials to the plain-text token realm %s", realm.Host)
+ }
+
+ query := realm.Query()
+ if answer.Service != "" {
+ query.Set("service", answer.Service)
+ }
+ if answer.Scope != "" {
+ query.Set("scope", answer.Scope)
+ }
+ realm.RawQuery = query.Encode()
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, realm.String(), nil)
+ if err != nil {
+ return "", fmt.Errorf("proxy: build token request: %w", err)
+ }
+ request.Header.Set("User-Agent", userAgent)
+ request.Header.Set("Accept", "application/json")
+ if credentialled {
+ request.SetBasicAuth(remote.Username, remote.Password)
+ }
+
+ response, err := c.http.Do(request)
+ if err != nil {
+ return "", fmt.Errorf("proxy: fetch a token from %s: %w", realm.Host, err)
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("proxy: %s answered %d for a token", realm.Host, response.StatusCode)
+ }
+
+ var issued tokenResponse
+ if err := json.NewDecoder(io.LimitReader(response.Body, maxTokenBytes)).Decode(&issued); err != nil {
+ return "", fmt.Errorf("proxy: reading a token from %s: %w", realm.Host, err)
+ }
+ if issued.value() == "" {
+ return "", fmt.Errorf("proxy: %s issued an empty token", realm.Host)
+ }
+
+ c.tokens.put(key, issued.value(), issued.lifetime())
+ return issued.value(), nil
+}
diff --git a/internal/server/api_docker.go b/internal/server/api_docker.go
new file mode 100644
index 0000000..67846ca
--- /dev/null
+++ b/internal/server/api_docker.go
@@ -0,0 +1,436 @@
+package server
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/charmbracelet/log"
+
+ "arca/internal/docker"
+ "arca/internal/format"
+ "arca/internal/store/models"
+)
+
+type dockerLayerResponse struct {
+ Digest string `json:"digest"`
+ Short string `json:"short"`
+ MediaType string `json:"mediaType"`
+ Size int64 `json:"size"`
+ Position int64 `json:"position"`
+ // SharedWith counts the other manifests here that need this layer, so the UI
+ // can explain why removing one tag reclaims less than its total size.
+ SharedWith int `json:"sharedWith"`
+ Foreign bool `json:"foreign"`
+ URLs []string `json:"urls"`
+ Stored bool `json:"stored"`
+}
+
+type dockerChildResponse struct {
+ Digest string `json:"digest"`
+ Short string `json:"short"`
+ MediaType string `json:"mediaType"`
+ Platform string `json:"platform"`
+ Size int64 `json:"size"`
+ TotalSize int64 `json:"totalSize"`
+ OS string `json:"os"`
+ Architecture string `json:"architecture"`
+ Variant string `json:"variant"`
+ LayerCount int `json:"layerCount"`
+ Indexed bool `json:"indexed"`
+ // ReferenceType names a child that is not a platform at all. buildkit attaches
+ // provenance and SBOM attestations to every multi-platform build, and they
+ // declare a platform of unknown/unknown, so without this they read as broken.
+ ReferenceType string `json:"referenceType"`
+}
+
+type dockerHistoryResponse struct {
+ Created int64 `json:"created"`
+ CreatedBy string `json:"createdBy"`
+ Comment string `json:"comment"`
+ EmptyLayer bool `json:"emptyLayer"`
+}
+
+type dockerConfigResponse struct {
+ Digest string `json:"digest"`
+ Size int64 `json:"size"`
+ User string `json:"user"`
+ WorkingDir string `json:"workingDir"`
+ Entrypoint []string `json:"entrypoint"`
+ Cmd []string `json:"cmd"`
+ Env []string `json:"env"`
+ ExposedPorts []string `json:"exposedPorts"`
+ History []dockerHistoryResponse `json:"history"`
+}
+
+type dockerManifestResponse struct {
+ Repository string `json:"repository"`
+ Namespace string `json:"namespace"`
+ Name string `json:"name"`
+ Image string `json:"image"`
+ Reference string `json:"reference"`
+ Digest string `json:"digest"`
+ Short string `json:"short"`
+ MediaType string `json:"mediaType"`
+ Size int64 `json:"size"`
+ TotalSize int64 `json:"totalSize"`
+ LayerCount int `json:"layerCount"`
+ IsIndex bool `json:"isIndex"`
+ OS string `json:"os"`
+ Architecture string `json:"architecture"`
+ Variant string `json:"variant"`
+ ImageCreated int64 `json:"imageCreated"`
+ Labels map[string]string `json:"labels"`
+ Annotations map[string]string `json:"annotations"`
+ Layers []dockerLayerResponse `json:"layers"`
+ Children []dockerChildResponse `json:"children"`
+ Config *dockerConfigResponse `json:"config"`
+ PullBy map[string]string `json:"pullBy"`
+}
+
+// routeDockerManifest describes one tag or digest: its layers, how much of each is
+// shared, the platforms of an index, and the config the image was built with. It is
+// what the file listing cannot be for docker, where a version's only file is a
+// manifest of a few kilobytes and its real weight lives in shared blobs.
+func (s *Server) routeDockerManifest(writer http.ResponseWriter, request *http.Request) error {
+ repository, _, err := s.loadAccessible(request, models.PermissionRead)
+ if err != nil {
+ return err
+ }
+ if repository.Format != format.Docker {
+ return fail(http.StatusBadRequest, "This is not a docker repository")
+ }
+
+ namespace, name, err := requireCoordinates(request)
+ if err != nil {
+ return err
+ }
+ reference, err := requireText(request.URL.Query().Get("reference"), "reference", 200)
+ if err != nil {
+ return err
+ }
+
+ image := docker.ImageName(namespace, name)
+ digest, err := s.resolveDockerReference(repository, image, reference)
+ if err != nil {
+ return err
+ }
+
+ record, err := s.store.DockerManifest(repository.ID, digest.String())
+ if err != nil {
+ return fail(http.StatusNotFound, "That manifest has not been indexed")
+ }
+ var config *docker.Config
+
+ references, err := s.store.DockerReferences(repository.ID, digest.String())
+ if err != nil {
+ return err
+ }
+
+ response := dockerManifestResponse{
+ Repository: repository.Name,
+ Namespace: namespace,
+ Name: name,
+ Image: image,
+ Reference: reference,
+ Digest: record.Digest,
+ Short: digest.Short(),
+ MediaType: record.MediaType,
+ Size: record.Size,
+ TotalSize: record.TotalSize,
+ LayerCount: record.LayerCount,
+ IsIndex: record.IsIndex(),
+ OS: record.OS,
+ Architecture: record.Architecture,
+ Variant: record.Variant,
+ ImageCreated: record.ImageCreated,
+ Labels: decodeJSONMap(record.Labels),
+ Annotations: decodeJSONMap(record.Annotations),
+ Layers: []dockerLayerResponse{},
+ Children: []dockerChildResponse{},
+ PullBy: map[string]string{
+ "tag": repository.Name + "/" + image + ":" + reference,
+ "digest": repository.Name + "/" + image + "@" + record.Digest,
+ },
+ }
+
+ if response.IsIndex {
+ response.Children, err = s.describeDockerChildren(repository, references)
+ if err == nil {
+ s.backfillDockerIndexTime(repository, record, &response)
+ }
+ } else {
+ response.Layers, err = s.describeDockerLayers(repository, references)
+ if err == nil {
+ response.Config, config = s.describeDockerConfig(repository, references)
+ }
+ }
+ if err != nil {
+ return err
+ }
+
+ // A proxy fetches a manifest before the config blob it points at, so indexing had
+ // nothing to read the platform from. Filling it in here, and writing it back, is
+ // what stops a proxied image reporting an unknown architecture for ever.
+ if config != nil {
+ s.backfillDockerPlatform(repository, record, *config, &response)
+ }
+
+ writeJSON(writer, http.StatusOK, response)
+ return nil
+}
+
+// resolveDockerReference turns a tag or a digest into the digest to look up. A tag
+// resolves through the asset that holds its manifest, whose own SHA256 is that
+// digest, so no tag table is needed.
+func (s *Server) resolveDockerReference(repository *models.Repository, image, reference string) (docker.Digest, error) {
+ if digest, ok := docker.ParseDigest(reference); ok {
+ return digest, nil
+ }
+ if !docker.IsValidTag(reference) {
+ return docker.Digest{}, fail(http.StatusBadRequest, "reference must be a tag or a digest")
+ }
+
+ asset, err := s.store.FindAsset(repository.ID, docker.TagPath(image, reference))
+ if err != nil {
+ return docker.Digest{}, fail(http.StatusNotFound, "There is no tag %q on %s", reference, image)
+ }
+ return docker.SHA256(asset.SHA256), nil
+}
+
+func (s *Server) describeDockerLayers(repository *models.Repository, references []models.DockerReference) ([]dockerLayerResponse, error) {
+ layers := make([]dockerLayerResponse, 0, len(references))
+
+ digests := make([]string, 0, len(references))
+ for _, reference := range references {
+ if reference.Kind == models.DockerReferenceLayer {
+ digests = append(digests, reference.ChildDigest)
+ }
+ }
+
+ sharing, err := s.store.DockerBlobSharing(repository.ID, digests)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, reference := range references {
+ if reference.Kind != models.DockerReferenceLayer {
+ continue
+ }
+
+ layer := dockerLayerResponse{
+ Digest: reference.ChildDigest,
+ MediaType: reference.MediaType,
+ Size: reference.Size,
+ Position: reference.Position,
+ Foreign: reference.IsForeign(),
+ URLs: splitLines(reference.URLs),
+ // The count includes this manifest, so what the UI wants is the rest.
+ SharedWith: max(sharing[reference.ChildDigest]-1, 0),
+ }
+ if digest, ok := docker.ParseDigest(reference.ChildDigest); ok {
+ layer.Short = digest.Short()
+ layer.Stored = s.dockerBlobStored(repository, digest)
+ }
+
+ layers = append(layers, layer)
+ }
+ return layers, nil
+}
+
+// dockerBlobStored separates a layer this server holds from one it only knows
+// about, which is the difference between a foreign layer and a broken one.
+func (s *Server) dockerBlobStored(repository *models.Repository, digest docker.Digest) bool {
+ _, err := s.store.FindAsset(repository.ID, docker.BlobPath(digest))
+ return err == nil
+}
+
+func (s *Server) describeDockerChildren(repository *models.Repository, references []models.DockerReference) ([]dockerChildResponse, error) {
+ digests := make([]string, 0, len(references))
+ for _, reference := range references {
+ if reference.Kind == models.DockerReferenceManifest {
+ digests = append(digests, reference.ChildDigest)
+ }
+ }
+
+ indexed, err := s.store.DockerManifestsByDigest(repository.ID, digests)
+ if err != nil {
+ return nil, err
+ }
+
+ children := make([]dockerChildResponse, 0, len(digests))
+ for _, reference := range references {
+ if reference.Kind != models.DockerReferenceManifest {
+ continue
+ }
+
+ child := dockerChildResponse{
+ Digest: reference.ChildDigest,
+ MediaType: reference.MediaType,
+ Platform: reference.Platform,
+ Size: reference.Size,
+ ReferenceType: decodeJSONMap(reference.Annotations)[docker.AnnotationReferenceType],
+ }
+ if digest, ok := docker.ParseDigest(reference.ChildDigest); ok {
+ child.Short = digest.Short()
+ }
+
+ // A child that has not been indexed is normal while an index is still being
+ // pushed, and permanent in a partially migrated repository, so it is
+ // reported as unindexed rather than left out.
+ if record, ok := indexed[reference.ChildDigest]; ok {
+ child.Indexed = true
+ child.TotalSize = record.TotalSize
+ child.OS = record.OS
+ child.Architecture = record.Architecture
+ child.Variant = record.Variant
+ child.LayerCount = record.LayerCount
+ }
+
+ children = append(children, child)
+ }
+ return children, nil
+}
+
+// describeDockerConfig reads the build-time settings out of the config blob. It is
+// read here rather than stored, because it is small, already local, and duplicating
+// it into a column would only give it a second chance to go stale.
+// backfillDockerPlatform repairs a record indexed before its config blob arrived. It
+// writes during a read, which is worth it: the alternative is a permanently blank
+// platform on every proxied image, and the value is derived rather than authored so
+// recomputing it costs nothing but the one update.
+func (s *Server) backfillDockerPlatform(repository *models.Repository, record *models.DockerManifest, config docker.Config, response *dockerManifestResponse) {
+ if record.Architecture != "" || config.Architecture == "" {
+ return
+ }
+
+ record.Architecture = config.Architecture
+ record.OS = config.OS
+ record.Variant = config.Variant
+ record.ImageCreated = config.Created
+ record.Labels = encodeJSONMap(config.Labels)
+
+ response.Architecture = record.Architecture
+ response.OS = record.OS
+ response.Variant = record.Variant
+ response.ImageCreated = record.ImageCreated
+ response.Labels = config.Labels
+
+ if err := s.store.UpdateDockerManifestPlatform(record); err != nil {
+ log.Warnf("recording the platform of %s/%s failed: %v", repository.Name, record.Digest, err)
+ }
+}
+
+// backfillDockerIndexTime is the same repair one level up. An index has no config, so
+// it takes its build time from its children, and on a proxy those are fetched after the
+// index itself and so were not indexed when it was.
+func (s *Server) backfillDockerIndexTime(repository *models.Repository, record *models.DockerManifest, response *dockerManifestResponse) {
+ if record.ImageCreated != 0 {
+ return
+ }
+
+ var newest int64
+ for _, child := range response.Children {
+ if indexed, err := s.store.DockerManifest(repository.ID, child.Digest); err == nil {
+ newest = max(newest, indexed.ImageCreated)
+ }
+ }
+ if newest == 0 {
+ return
+ }
+
+ record.ImageCreated = newest
+ response.ImageCreated = newest
+
+ if err := s.store.UpdateDockerManifestPlatform(record); err != nil {
+ log.Warnf("recording the build time of %s/%s failed: %v", repository.Name, record.Digest, err)
+ }
+}
+
+func (s *Server) describeDockerConfig(repository *models.Repository, references []models.DockerReference) (*dockerConfigResponse, *docker.Config) {
+ var entry models.DockerReference
+ for _, reference := range references {
+ if reference.Kind == models.DockerReferenceConfig {
+ entry = reference
+ break
+ }
+ }
+ if entry.ChildDigest == "" {
+ return nil, nil
+ }
+
+ digest, ok := docker.ParseDigest(entry.ChildDigest)
+ if !ok {
+ return nil, nil
+ }
+
+ asset, err := s.store.FindAsset(repository.ID, docker.BlobPath(digest))
+ if err != nil {
+ return nil, nil
+ }
+
+ file, _, err := s.blobs.Open(asset.StorageKey)
+ if err != nil {
+ return nil, nil
+ }
+ defer file.Close()
+
+ document, err := io.ReadAll(io.LimitReader(file, maxManifestBytes))
+ if err != nil {
+ return nil, nil
+ }
+
+ config, err := docker.ParseConfig(document)
+ if err != nil {
+ return nil, nil
+ }
+
+ response := &dockerConfigResponse{
+ Digest: entry.ChildDigest,
+ Size: entry.Size,
+ User: config.User,
+ WorkingDir: config.WorkingDir,
+ Entrypoint: orEmpty(config.Entrypoint),
+ Cmd: orEmpty(config.Cmd),
+ Env: orEmpty(config.Env),
+ ExposedPorts: orEmpty(config.ExposedPorts),
+ History: []dockerHistoryResponse{},
+ }
+ for _, step := range config.History {
+ response.History = append(response.History, dockerHistoryResponse{
+ Created: step.Created,
+ CreatedBy: step.CreatedBy,
+ Comment: step.Comment,
+ EmptyLayer: step.EmptyLayer,
+ })
+ }
+ return response, &config
+}
+
+func decodeJSONMap(encoded string) map[string]string {
+ values := map[string]string{}
+ if encoded == "" {
+ return values
+ }
+ if err := json.Unmarshal([]byte(encoded), &values); err != nil {
+ return map[string]string{}
+ }
+ return values
+}
+
+func splitLines(value string) []string {
+ if value == "" {
+ return []string{}
+ }
+ return strings.Split(value, "\n")
+}
+
+// orEmpty keeps a JSON array out of being null, which the UI would have to guard
+// every read against.
+func orEmpty(values []string) []string {
+ if values == nil {
+ return []string{}
+ }
+ return values
+}
diff --git a/internal/server/api_maintenance.go b/internal/server/api_maintenance.go
index db9d3b0..d3f16c6 100644
--- a/internal/server/api_maintenance.go
+++ b/internal/server/api_maintenance.go
@@ -44,7 +44,7 @@ func (s *Server) plan(ctx context.Context, body migrationRequest) ([]migrate.Dec
}
}
if body.RawFormat != "" {
- if _, err := requireOneOf(body.RawFormat, formats, "rawFormat"); err != nil {
+ if _, err := requireOneOf(body.RawFormat, rawFormats, "rawFormat"); err != nil {
return nil, err
}
}
@@ -176,6 +176,53 @@ func (s *Server) runMigration(ctx context.Context, cancel context.CancelFunc, ru
}
}
+// routeDockerSweepPreview reports what a sweep would reclaim without touching
+// anything, so an administrator can see the number before agreeing to it.
+func (s *Server) routeDockerSweepPreview(writer http.ResponseWriter, request *http.Request) error {
+ if _, err := requireAdmin(request); err != nil {
+ return err
+ }
+
+ results, err := s.sweepDockerRepositories(false)
+ if err != nil {
+ return err
+ }
+
+ writeJSON(writer, http.StatusOK, map[string]any{"repositories": results})
+ return nil
+}
+
+func (s *Server) routeDockerSweep(writer http.ResponseWriter, request *http.Request) error {
+ if _, err := requireAdmin(request); err != nil {
+ return err
+ }
+
+ results, err := s.sweepDockerRepositories(true)
+ if err != nil {
+ return err
+ }
+
+ writeJSON(writer, http.StatusOK, map[string]any{"repositories": results})
+ return nil
+}
+
+// routeDockerReindex rebuilds the parsed metadata of every docker repository. It
+// repairs a migration that was interrupted, or one run before this server knew how to
+// index, without recopying a byte.
+func (s *Server) routeDockerReindex(writer http.ResponseWriter, request *http.Request) error {
+ if _, err := requireAdmin(request); err != nil {
+ return err
+ }
+
+ results, err := s.reindexDockerRepositories()
+ if err != nil {
+ return err
+ }
+
+ writeJSON(writer, http.StatusOK, map[string]any{"repositories": results})
+ return nil
+}
+
func (s *Server) routeCancelMigration(writer http.ResponseWriter, request *http.Request) error {
if _, err := requireAdmin(request); err != nil {
return err
diff --git a/internal/server/api_repositories.go b/internal/server/api_repositories.go
index 467b937..60087fe 100644
--- a/internal/server/api_repositories.go
+++ b/internal/server/api_repositories.go
@@ -21,7 +21,11 @@ var (
visibilities = []string{models.VisibilityPublic, models.VisibilityPrivate}
permissions = []string{models.PermissionRead, models.PermissionWrite, models.PermissionAdmin}
repositoryTypes = []string{models.TypeHosted, models.TypeProxy, models.TypeGroup}
- formats = []string{format.Maven2, format.NPM, format.P2}
+ formats = []string{format.Maven2, format.NPM, format.P2, format.Docker}
+ // rawFormats are what a Nexus raw repository may be migrated into. Docker is
+ // absent on purpose: raw holds loose files, while docker content only means
+ // anything at the paths the registry API defines, so the mapping cannot exist.
+ rawFormats = []string{format.Maven2, format.NPM, format.P2}
)
type repositoryResponse struct {
diff --git a/internal/server/docker.go b/internal/server/docker.go
new file mode 100644
index 0000000..7fc392f
--- /dev/null
+++ b/internal/server/docker.go
@@ -0,0 +1,322 @@
+package server
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/charmbracelet/log"
+
+ "arca/internal/docker"
+ "arca/internal/format"
+ "arca/internal/store"
+ "arca/internal/store/models"
+)
+
+const (
+ maxManifestBytes = 4 << 20
+ blobContentType = "application/octet-stream"
+)
+
+// dockerBlobLimit translates the configured ceiling into what storeUpload expects,
+// where a negative limit means unlimited and zero would fall back to the much
+// smaller artifact limit.
+func (s *Server) dockerBlobLimit() int64 {
+ if s.config.MaxBlobBytes > 0 {
+ return s.config.MaxBlobBytes
+ }
+ return -1
+}
+
+func dockerError(writer http.ResponseWriter, status int, code, message string) {
+ writeJSON(writer, status, map[string]any{
+ "errors": []map[string]any{{"code": code, "message": message, "detail": nil}},
+ })
+}
+
+func (s *Server) dockerChallenge(writer http.ResponseWriter, request *http.Request, message string) {
+ if currentUser(request) != nil {
+ dockerError(writer, http.StatusForbidden, docker.ErrorDenied, message)
+ return
+ }
+ writer.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", s.store.InstanceName()))
+ dockerError(writer, http.StatusUnauthorized, docker.ErrorUnauthorized, message)
+}
+
+// registryRequest is one resolved registry call. Route.Name is kept alongside the
+// image because every Location header has to echo the name the client used, which
+// carries the repository prefix that the image name on its own has lost.
+type registryRequest struct {
+ repository *models.Repository
+ image string
+ route docker.Route
+}
+
+func (r registryRequest) namespace() (string, string, bool) { return docker.SplitImage(r.image) }
+
+func (r registryRequest) uploadLocation(id string) string {
+ return "/v2/" + r.route.Name + "/blobs/uploads/" + id
+}
+
+// handleDockerRegistry serves /v2 at the host root, because a Docker client
+// derives the registry from the image reference and has no way to be pointed at a
+// path prefix the way Maven and npm clients do.
+func (s *Server) handleDockerRegistry(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set(docker.APIVersionHeader, docker.APIVersion)
+
+ route := docker.Resolve(request.Method, strings.TrimPrefix(request.URL.Path, "/v2"))
+
+ switch route.Kind {
+ case docker.RouteBase:
+ s.serveDockerBase(writer, request)
+ return
+ case docker.RouteCatalog:
+ s.serveDockerCatalog(writer, request)
+ return
+ case docker.RouteUnknown:
+ dockerError(writer, http.StatusNotFound, docker.ErrorNameInvalid, "Not found")
+ return
+ }
+
+ repository, image, ok := s.resolveDockerRepository(route.Name)
+ if !ok {
+ dockerError(writer, http.StatusNotFound, docker.ErrorNameUnknown,
+ fmt.Sprintf("%q does not name a repository and an image on this server", route.Name))
+ return
+ }
+
+ required := models.PermissionRead
+ switch request.Method {
+ case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
+ required = models.PermissionWrite
+ }
+ if !s.authorizeDocker(writer, request, repository, required) {
+ return
+ }
+ if required != models.PermissionRead && (repository.IsProxy() || repository.IsGroup()) {
+ dockerError(writer, http.StatusMethodNotAllowed, docker.ErrorUnsupported, "This repository is read-only")
+ return
+ }
+
+ registry := registryRequest{repository: repository, image: image, route: route}
+
+ switch route.Kind {
+ case docker.RouteTags:
+ s.serveDockerTags(writer, request, registry)
+ case docker.RouteManifest:
+ s.serveDockerManifest(writer, request, registry)
+ case docker.RouteBlob:
+ s.serveDockerBlob(writer, request, registry)
+ case docker.RouteUploadStart:
+ s.serveDockerUploadStart(writer, request, registry)
+ case docker.RouteUpload:
+ s.serveDockerUploadSession(writer, request, registry)
+ case docker.RouteReferrers:
+ // Unimplemented on purpose. A 404 is the spec's own signal for that and
+ // it sends clients to the fallback tag scheme.
+ dockerError(writer, http.StatusNotFound, docker.ErrorUnsupported, "The referrers API is not implemented")
+ default:
+ dockerError(writer, http.StatusNotFound, docker.ErrorNameUnknown, "Not found")
+ }
+}
+
+func (s *Server) authorizeDocker(writer http.ResponseWriter, request *http.Request, repository *models.Repository, required string) bool {
+ permission, err := s.store.EffectivePermission(repository, currentUser(request))
+ if err != nil {
+ log.Errorf("resolving repository permission failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return false
+ }
+ if store.Satisfies(permission, required) {
+ return true
+ }
+
+ s.dockerChallenge(writer, request, "You do not have permission to do that")
+ return false
+}
+
+// resolveDockerRepository splits an image name into the repository serving it and
+// the image inside it. The leading segment wins when it names a docker
+// repository, so "docker-hosted/team/api" is the team/api image of docker-hosted.
+// Otherwise the whole name resolves against the instance default, which is what
+// makes a bare "docker pull host/nginx" work on a single-repository install.
+func (s *Server) resolveDockerRepository(name string) (*models.Repository, string, bool) {
+ if prefix, rest, found := strings.Cut(name, "/"); found && rest != "" {
+ repository, err := s.store.RepositoryByNameAndFormat(prefix, format.Docker)
+ if err == nil && docker.IsValidName(rest) {
+ return repository, rest, true
+ }
+ }
+
+ fallback, err := s.store.Setting(store.SettingDockerRepository)
+ if err != nil || fallback == "" {
+ return nil, "", false
+ }
+
+ repository, err := s.store.RepositoryByNameAndFormat(fallback, format.Docker)
+ if err != nil {
+ return nil, "", false
+ }
+ return repository, name, true
+}
+
+// serveDockerBase answers the version check. It is the one endpoint that names no
+// repository, so it cannot consult a grant: it succeeds for anyone who
+// authenticated, and for anonymous clients only when some docker repository is
+// public. Answering 401 with a challenge is also what makes docker login work,
+// since that command has nothing else to post to.
+func (s *Server) serveDockerBase(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != http.MethodGet && request.Method != http.MethodHead {
+ dockerError(writer, http.StatusMethodNotAllowed, docker.ErrorUnsupported, "Method not allowed")
+ return
+ }
+
+ if currentUser(request) != nil {
+ writeJSON(writer, http.StatusOK, map[string]any{})
+ return
+ }
+
+ public, err := s.store.HasPublicRepositories(format.Docker)
+ if err != nil {
+ log.Errorf("checking for public docker repositories failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+ if public {
+ writeJSON(writer, http.StatusOK, map[string]any{})
+ return
+ }
+
+ s.dockerChallenge(writer, request, "Authentication required")
+}
+
+// serveDockerCatalog lists every image the caller can read, each prefixed with
+// the repository that serves it. The spec assumes one registry per host, so the
+// prefix is an addition rather than an omission: without it a client could not
+// turn a catalog entry back into something it can pull.
+func (s *Server) serveDockerCatalog(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != http.MethodGet {
+ dockerError(writer, http.StatusMethodNotAllowed, docker.ErrorUnsupported, "Method not allowed")
+ return
+ }
+
+ repositories, err := s.store.VisibleRepositories(currentUser(request))
+ if err != nil {
+ log.Errorf("listing repositories for the docker catalog failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+
+ names := []string{}
+ for _, repository := range repositories {
+ if repository.Format != format.Docker {
+ continue
+ }
+
+ images, err := s.store.DockerImages(repository.ID)
+ if err != nil {
+ log.Errorf("listing images of %s failed: %v", repository.Name, err)
+ continue
+ }
+ for _, image := range images {
+ names = append(names, repository.Name+"/"+image)
+ }
+ }
+
+ writeJSON(writer, http.StatusOK, map[string]any{"repositories": names})
+}
+
+func (s *Server) serveDockerTags(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ if request.Method != http.MethodGet && request.Method != http.MethodHead {
+ dockerError(writer, http.StatusMethodNotAllowed, docker.ErrorUnsupported, "Method not allowed")
+ return
+ }
+
+ namespace, name, ok := registry.namespace()
+ if !ok {
+ dockerError(writer, http.StatusNotFound, docker.ErrorNameInvalid, "Not found")
+ return
+ }
+
+ components, err := s.store.ComponentVersions(registry.repository.ID, namespace, name)
+ if err != nil {
+ log.Errorf("listing tags failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+ if len(components) == 0 {
+ dockerError(writer, http.StatusNotFound, docker.ErrorNameUnknown, "Not found")
+ return
+ }
+
+ tags := make([]string, 0, len(components))
+ for _, component := range components {
+ tags = append(tags, component.Version)
+ }
+
+ writeJSON(writer, http.StatusOK, map[string]any{
+ "name": registry.route.Name,
+ // Sorting through the format's own comparator is what makes the spec's
+ // "last" parameter stable across requests.
+ "tags": paginateTags(format.SortVersions(docker.Layout{}, tags), request),
+ })
+}
+
+// serveDockerBrowse answers /repository//. A docker repository is pulled
+// from at /v2 instead, so this exists only so that following the URL the rest of
+// the UI shows lands on the stored tree rather than on a Maven handler.
+func (s *Server) serveDockerBrowse(writer http.ResponseWriter, request *http.Request, repository *models.Repository, path string) {
+ if request.Method != http.MethodGet && request.Method != http.MethodHead {
+ plain(writer, http.StatusMethodNotAllowed, "Push to /v2/"+repository.Name+"/ instead")
+ return
+ }
+ if !s.authorize(writer, request, repository, models.PermissionRead) {
+ return
+ }
+
+ if path == "" || strings.HasSuffix(path, "/") {
+ exists, err := s.store.DirectoryExists(repository.ID, path)
+ if err != nil {
+ log.Errorf("directory lookup failed: %v", err)
+ plain(writer, http.StatusInternalServerError, "Internal Server Error")
+ return
+ }
+ if !exists {
+ plain(writer, http.StatusNotFound, "Not Found")
+ return
+ }
+ s.writeDirectoryIndex(writer, repository, path)
+ return
+ }
+
+ asset, err := s.store.FindAsset(repository.ID, path)
+ if err != nil {
+ plain(writer, http.StatusNotFound, "Not Found")
+ return
+ }
+ if request.Method == http.MethodGet {
+ s.recordAssetTraffic(request, models.TrafficDownload, repository, asset)
+ }
+ if !s.writeAsset(writer, request, asset) {
+ plain(writer, http.StatusNotFound, "Not Found")
+ }
+}
+
+func paginateTags(tags []string, request *http.Request) []string {
+ query := request.URL.Query()
+
+ if last := query.Get("last"); last != "" {
+ for index, tag := range tags {
+ if tag == last {
+ tags = tags[index+1:]
+ break
+ }
+ }
+ }
+
+ if limit, err := strconv.Atoi(query.Get("n")); err == nil && limit >= 0 && len(tags) > limit {
+ tags = tags[:limit]
+ }
+ return tags
+}
diff --git a/internal/server/docker_blob.go b/internal/server/docker_blob.go
new file mode 100644
index 0000000..fd5724a
--- /dev/null
+++ b/internal/server/docker_blob.go
@@ -0,0 +1,471 @@
+package server
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/log"
+
+ "arca/internal/blob"
+ "arca/internal/docker"
+ "arca/internal/store"
+ "arca/internal/store/models"
+)
+
+// staleUploadWindow is how long an upload session survives without a chunk. A
+// push of a large layer over a slow link is one long PATCH rather than a pause,
+// so a day is generous without letting an abandoned session linger.
+const staleUploadWindow = 24 * time.Hour
+
+func (s *Server) serveDockerBlob(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ switch request.Method {
+ case http.MethodGet, http.MethodHead:
+ s.serveDockerBlobRead(writer, request, registry)
+ case http.MethodDelete:
+ s.serveDockerBlobDelete(writer, registry)
+ default:
+ dockerError(writer, http.StatusMethodNotAllowed, docker.ErrorUnsupported, "Method not allowed")
+ }
+}
+
+func (s *Server) serveDockerBlobRead(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ if registry.repository.IsProxy() {
+ s.serveDockerProxyBlob(writer, request, registry)
+ return
+ }
+
+ asset, err := s.store.FindAsset(registry.repository.ID, docker.BlobPath(registry.route.Digest))
+ if err != nil {
+ dockerError(writer, http.StatusNotFound, docker.ErrorBlobUnknown, "Not found")
+ return
+ }
+
+ writer.Header().Set(docker.ContentDigestHeader, registry.route.Digest.String())
+
+ // A blob request names the image but not the tag, so traffic is recorded
+ // against the image with no version rather than guessed at.
+ if request.Method == http.MethodGet {
+ s.recordDockerTraffic(request, registry, models.TrafficDownload, asset.Size)
+ }
+ if !s.writeAsset(writer, request, asset) {
+ dockerError(writer, http.StatusNotFound, docker.ErrorBlobUnknown, "Not found")
+ }
+}
+
+func (s *Server) serveDockerBlobDelete(writer http.ResponseWriter, registry registryRequest) {
+ keys, err := s.store.DeleteAsset(registry.repository.ID, docker.BlobPath(registry.route.Digest))
+ if err != nil {
+ log.Errorf("deleting a docker blob failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+ if len(keys) == 0 {
+ dockerError(writer, http.StatusNotFound, docker.ErrorBlobUnknown, "Not found")
+ return
+ }
+ if err := s.blobs.Delete(keys...); err != nil {
+ log.Errorf("removing a deleted docker blob failed: %v", err)
+ }
+
+ writer.WriteHeader(http.StatusAccepted)
+}
+
+// serveDockerUploadStart opens a push. The same endpoint covers three shapes: a
+// mount of a blob this server already holds, a whole blob in the body, and the
+// session a chunked push appends to.
+func (s *Server) serveDockerUploadStart(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ if request.Method != http.MethodPost {
+ dockerError(writer, http.StatusMethodNotAllowed, docker.ErrorUnsupported, "Method not allowed")
+ return
+ }
+
+ query := request.URL.Query()
+
+ if mount := query.Get("mount"); mount != "" {
+ if s.mountDockerBlob(writer, request, registry, mount, query.Get("from")) {
+ return
+ }
+ // A mount that cannot be served falls through to an ordinary session,
+ // which is the fallback the spec prescribes.
+ }
+
+ if digest := query.Get("digest"); digest != "" {
+ s.completeDockerMonolith(writer, request, registry, digest)
+ return
+ }
+
+ id := store.NewID()
+ if err := s.blobs.BeginUpload(id); err != nil {
+ log.Errorf("opening a docker upload session failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+
+ var userID *string
+ if user := currentUser(request); user != nil {
+ userID = &user.ID
+ }
+ upload := &models.DockerUpload{
+ ID: id,
+ RepositoryID: registry.repository.ID,
+ Image: registry.image,
+ UserID: userID,
+ }
+ if err := s.store.CreateDockerUpload(upload); err != nil {
+ _ = s.blobs.AbortUpload(id)
+ log.Errorf("recording a docker upload session failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+
+ writeDockerUploadProgress(writer, registry, id, 0, http.StatusAccepted)
+}
+
+// mountDockerBlob reports whether it answered. Blob keys are scoped per
+// repository, so the bytes are copied rather than linked, which still saves the
+// client the upload and keeps per-repository storage accounting truthful.
+func (s *Server) mountDockerBlob(writer http.ResponseWriter, request *http.Request, registry registryRequest, mount, from string) bool {
+ digest, ok := docker.ParseDigest(mount)
+ if !ok {
+ return false
+ }
+
+ source := registry.repository
+ if from != "" && from != registry.route.Name {
+ found, err := s.dockerMountSource(request, from)
+ if err != nil {
+ return false
+ }
+ source = found
+ }
+
+ existing, err := s.store.FindAsset(source.ID, docker.BlobPath(digest))
+ if err != nil {
+ return false
+ }
+
+ file, _, err := s.blobs.Open(existing.StorageKey)
+ if err != nil {
+ return false
+ }
+ defer file.Close()
+
+ var uploadedBy *string
+ if user := currentUser(request); user != nil {
+ uploadedBy = &user.ID
+ }
+
+ stored, err := s.storeUpload(registry.repository, docker.BlobPath(digest), file,
+ uploadDetails{UploadedBy: uploadedBy, ContentType: existing.ContentType, Limit: s.dockerBlobLimit()})
+ if err != nil {
+ log.Errorf("mounting a docker blob failed: %v", err)
+ return false
+ }
+ if stored.SHA256 != digest.Hex {
+ log.Warnf("a mounted blob of %s hashed to %s rather than %s", source.Name, stored.SHA256, digest.Hex)
+ if _, err := s.store.DeleteAsset(registry.repository.ID, stored.Path); err != nil {
+ log.Errorf("dropping a mismatched mounted blob failed: %v", err)
+ }
+ return false
+ }
+
+ writeDockerBlobCreated(writer, registry, digest)
+ return true
+}
+
+// dockerMountSource resolves the repository a cross-repository mount reads from,
+// which the caller must be able to read in its own right.
+func (s *Server) dockerMountSource(request *http.Request, from string) (*models.Repository, error) {
+ repository, _, ok := s.resolveDockerRepository(from)
+ if !ok {
+ return nil, errors.New("server: the mount source names no repository")
+ }
+
+ permission, err := s.store.EffectivePermission(repository, currentUser(request))
+ if err != nil {
+ return nil, err
+ }
+ if !store.Satisfies(permission, models.PermissionRead) {
+ return nil, errors.New("server: the mount source is not readable by this user")
+ }
+ return repository, nil
+}
+
+// completeDockerMonolith stores a blob whose whole body arrived with the POST or
+// PUT that named its digest.
+func (s *Server) completeDockerMonolith(writer http.ResponseWriter, request *http.Request, registry registryRequest, raw string) {
+ digest, ok := docker.ParseDigest(raw)
+ if !ok {
+ dockerError(writer, http.StatusBadRequest, docker.ErrorDigestInvalid, raw+" is not a digest this server stores")
+ return
+ }
+
+ var uploadedBy *string
+ if user := currentUser(request); user != nil {
+ uploadedBy = &user.ID
+ }
+
+ path := docker.BlobPath(digest)
+ stored, err := s.storeUpload(registry.repository, path, request.Body,
+ uploadDetails{UploadedBy: uploadedBy, ContentType: blobContentType, Limit: s.dockerBlobLimit()})
+ if errors.Is(err, blob.ErrTooLarge) {
+ dockerError(writer, http.StatusRequestEntityTooLarge, docker.ErrorSizeInvalid, "The blob exceeds the upload limit")
+ return
+ }
+ if err != nil {
+ log.Errorf("storing a docker blob failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+
+ if stored.SHA256 != digest.Hex {
+ if _, deleteErr := s.store.DeleteAsset(registry.repository.ID, path); deleteErr != nil {
+ log.Errorf("dropping a mismatched docker blob failed: %v", deleteErr)
+ }
+ _ = s.blobs.Delete(stored.StorageKey)
+ dockerError(writer, http.StatusBadRequest, docker.ErrorDigestInvalid,
+ "The blob hashed to sha256:"+stored.SHA256+" rather than "+digest.String())
+ return
+ }
+
+ s.recordDockerTraffic(request, registry, models.TrafficUpload, stored.Size)
+ writeDockerBlobCreated(writer, registry, digest)
+}
+
+func (s *Server) serveDockerUploadSession(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ id := registry.route.Upload
+
+ if _, err := s.store.DockerUpload(registry.repository.ID, id); err != nil {
+ dockerError(writer, http.StatusNotFound, docker.ErrorBlobUploadUnknown, "Not found")
+ return
+ }
+
+ switch request.Method {
+ case http.MethodGet, http.MethodHead:
+ s.serveDockerUploadStatus(writer, registry, id)
+ case http.MethodPatch:
+ s.serveDockerUploadChunk(writer, request, registry, id)
+ case http.MethodPut:
+ s.serveDockerUploadFinish(writer, request, registry, id)
+ case http.MethodDelete:
+ s.serveDockerUploadAbort(writer, registry, id)
+ default:
+ dockerError(writer, http.StatusMethodNotAllowed, docker.ErrorUnsupported, "Method not allowed")
+ }
+}
+
+func (s *Server) serveDockerUploadStatus(writer http.ResponseWriter, registry registryRequest, id string) {
+ size, err := s.blobs.UploadSize(id)
+ if err != nil {
+ dockerError(writer, http.StatusNotFound, docker.ErrorBlobUploadUnknown, "Not found")
+ return
+ }
+ writeDockerUploadProgress(writer, registry, id, size, http.StatusNoContent)
+}
+
+func (s *Server) serveDockerUploadChunk(writer http.ResponseWriter, request *http.Request, registry registryRequest, id string) {
+ current, err := s.blobs.UploadSize(id)
+ if err != nil {
+ dockerError(writer, http.StatusNotFound, docker.ErrorBlobUploadUnknown, "Not found")
+ return
+ }
+
+ // A chunk that does not start where the session ended would silently corrupt
+ // the blob, so it is refused with the offset the client should resume from.
+ if start, ok := parseContentRangeStart(request.Header.Get("Content-Range")); ok && start != current {
+ writer.Header().Set("Range", "0-"+strconv.FormatInt(current-1, 10))
+ dockerError(writer, http.StatusRequestedRangeNotSatisfiable, docker.ErrorBlobUploadInvalid,
+ "The chunk does not continue from the end of the session")
+ return
+ }
+
+ size, err := s.blobs.AppendUpload(id, request.Body, s.config.MaxBlobBytes)
+ if errors.Is(err, blob.ErrTooLarge) {
+ dockerError(writer, http.StatusRequestEntityTooLarge, docker.ErrorSizeInvalid, "The blob exceeds the upload limit")
+ return
+ }
+ if err != nil {
+ log.Errorf("appending to a docker upload session failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+
+ if err := s.store.SetDockerUploadSize(id, size); err != nil {
+ log.Errorf("recording docker upload progress failed: %v", err)
+ }
+
+ writeDockerUploadProgress(writer, registry, id, size, http.StatusAccepted)
+}
+
+func (s *Server) serveDockerUploadFinish(writer http.ResponseWriter, request *http.Request, registry registryRequest, id string) {
+ digest, ok := docker.ParseDigest(request.URL.Query().Get("digest"))
+ if !ok {
+ dockerError(writer, http.StatusBadRequest, docker.ErrorDigestInvalid, "The upload was committed without a valid digest")
+ return
+ }
+
+ // The final PUT may carry the last chunk, and for a small blob it carries the
+ // whole of it.
+ if _, err := s.blobs.AppendUpload(id, request.Body, s.config.MaxBlobBytes); errors.Is(err, blob.ErrTooLarge) {
+ dockerError(writer, http.StatusRequestEntityTooLarge, docker.ErrorSizeInvalid, "The blob exceeds the upload limit")
+ return
+ } else if err != nil {
+ log.Errorf("appending the final docker chunk failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+
+ path := docker.BlobPath(digest)
+ key := registry.repository.ID + "/" + path
+
+ size, digests, err := s.blobs.CompleteUpload(id, key, digest.Hex)
+ if errors.Is(err, blob.ErrDigestMismatch) {
+ dockerError(writer, http.StatusBadRequest, docker.ErrorDigestInvalid,
+ "The upload hashed to sha256:"+digests.SHA256+" rather than "+digest.String())
+ return
+ }
+ if err != nil {
+ log.Errorf("committing a docker upload failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+
+ var uploadedBy *string
+ if user := currentUser(request); user != nil {
+ uploadedBy = &user.ID
+ }
+
+ // The bytes are already in place, so the asset row is written directly rather
+ // than through storeUpload, which would want to copy them a second time.
+ asset := &models.Asset{
+ RepositoryID: registry.repository.ID,
+ Path: path,
+ StorageKey: key,
+ Size: size,
+ ContentType: blobContentType,
+ MD5: digests.MD5,
+ SHA1: digests.SHA1,
+ SHA256: digests.SHA256,
+ SHA512: digests.SHA512,
+ UploadedBy: uploadedBy,
+ }
+ if err := s.store.UpsertAsset(asset); err != nil {
+ log.Errorf("recording a docker blob failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+ if err := s.store.DeleteDockerUpload(id); err != nil {
+ log.Errorf("closing a docker upload session failed: %v", err)
+ }
+
+ s.recordDockerTraffic(request, registry, models.TrafficUpload, size)
+ writeDockerBlobCreated(writer, registry, digest)
+}
+
+func (s *Server) serveDockerUploadAbort(writer http.ResponseWriter, registry registryRequest, id string) {
+ if err := s.blobs.AbortUpload(id); err != nil {
+ log.Errorf("discarding a docker upload session failed: %v", err)
+ }
+ if err := s.store.DeleteDockerUpload(id); err != nil {
+ log.Errorf("closing a docker upload session failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+ writer.WriteHeader(http.StatusNoContent)
+}
+
+func writeDockerUploadProgress(writer http.ResponseWriter, registry registryRequest, id string, size int64, status int) {
+ header := writer.Header()
+ header.Set("Location", registry.uploadLocation(id))
+ header.Set("Docker-Upload-UUID", id)
+ // An empty session reports 0-0 rather than 0--1, which is what the spec's
+ // inclusive range would otherwise produce.
+ if size == 0 {
+ header.Set("Range", "0-0")
+ } else {
+ header.Set("Range", "0-"+strconv.FormatInt(size-1, 10))
+ }
+ header.Set("Content-Length", "0")
+ writer.WriteHeader(status)
+}
+
+func writeDockerBlobCreated(writer http.ResponseWriter, registry registryRequest, digest docker.Digest) {
+ header := writer.Header()
+ header.Set("Location", "/v2/"+registry.route.Name+"/blobs/"+digest.String())
+ header.Set(docker.ContentDigestHeader, digest.String())
+ header.Set("Content-Length", "0")
+ writer.WriteHeader(http.StatusCreated)
+}
+
+// parseContentRangeStart reads the offset a chunk claims to begin at. The registry
+// API uses a bare "start-end" here rather than the "bytes start-end/total" form
+// that HTTP defines, so this is deliberately narrow.
+func parseContentRangeStart(value string) (int64, bool) {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return 0, false
+ }
+
+ start, _, found := strings.Cut(trimmed, "-")
+ if !found {
+ return 0, false
+ }
+
+ offset, err := strconv.ParseInt(strings.TrimSpace(start), 10, 64)
+ if err != nil || offset < 0 {
+ return 0, false
+ }
+ return offset, true
+}
+
+// recordDockerTraffic attributes a blob transfer to the image. A blob request
+// names no tag, so the version is left empty rather than guessed at.
+func (s *Server) recordDockerTraffic(request *http.Request, registry registryRequest, kind string, bytes int64) {
+ namespace, name, ok := registry.namespace()
+ if !ok {
+ return
+ }
+
+ var userID *string
+ if user := currentUser(request); user != nil {
+ userID = &user.ID
+ }
+
+ s.traffic.record(models.TrafficEvent{
+ Kind: kind,
+ RepositoryID: registry.repository.ID,
+ Namespace: namespace,
+ Name: name,
+ Bytes: bytes,
+ UserID: userID,
+ })
+}
+
+// purgeStaleDockerUploads drops sessions a client walked away from. The file goes
+// first: a row without a file reports a length nothing can satisfy, while a file
+// without a row is unreachable and would never be swept again.
+func (s *Server) purgeStaleDockerUploads(now time.Time) {
+ ids, err := s.store.StaleDockerUploads(now.Add(-staleUploadWindow).UnixMilli())
+ if err != nil {
+ log.Errorf("listing stale docker uploads failed: %v", err)
+ return
+ }
+ if len(ids) == 0 {
+ return
+ }
+
+ for _, id := range ids {
+ if err := s.blobs.AbortUpload(id); err != nil {
+ log.Errorf("discarding the stale docker upload %s failed: %v", id, err)
+ }
+ }
+ if err := s.store.DeleteDockerUploads(ids); err != nil {
+ log.Errorf("removing stale docker upload records failed: %v", err)
+ return
+ }
+
+ log.Infof("discarded %d abandoned docker upload sessions", len(ids))
+}
diff --git a/internal/server/docker_manifest.go b/internal/server/docker_manifest.go
new file mode 100644
index 0000000..701e1c5
--- /dev/null
+++ b/internal/server/docker_manifest.go
@@ -0,0 +1,359 @@
+package server
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/charmbracelet/log"
+
+ "arca/internal/docker"
+ "arca/internal/store/models"
+)
+
+func (s *Server) serveDockerManifest(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ switch request.Method {
+ case http.MethodGet, http.MethodHead:
+ s.serveDockerManifestRead(writer, request, registry)
+ case http.MethodPut:
+ s.serveDockerManifestPut(writer, request, registry)
+ case http.MethodDelete:
+ s.serveDockerManifestDelete(writer, registry)
+ default:
+ dockerError(writer, http.StatusMethodNotAllowed, docker.ErrorUnsupported, "Method not allowed")
+ }
+}
+
+// manifestPathFor names where a reference is stored. A digest reads from the
+// content-addressed store and a tag from its own directory, which holds the same
+// bytes so that a tag pull needs no second lookup.
+func manifestPathFor(registry registryRequest) string {
+ if registry.route.ByDigest {
+ return docker.ManifestPath(registry.image, registry.route.Digest)
+ }
+ return docker.TagPath(registry.image, registry.route.Tag)
+}
+
+func (s *Server) serveDockerManifestRead(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ if registry.repository.IsProxy() {
+ s.serveDockerProxyManifest(writer, request, registry)
+ return
+ }
+
+ asset, err := s.store.FindAsset(registry.repository.ID, manifestPathFor(registry))
+ if err != nil {
+ dockerError(writer, http.StatusNotFound, docker.ErrorManifestUnknown, "Not found")
+ return
+ }
+
+ // A client that asked for manifest types and excluded the stored one is told
+ // the manifest is absent, which is the spec's answer and is what sends it on to
+ // another reference rather than leaving it to choke on a type it cannot read.
+ if accept := request.Header.Get("Accept"); !docker.Accepts(accept, asset.ContentType) {
+ log.Warnf("%s/%s is stored as %s, which %q does not accept",
+ registry.repository.Name, registry.image, asset.ContentType, accept)
+ dockerError(writer, http.StatusNotFound, docker.ErrorManifestUnknown,
+ "This manifest is stored as "+asset.ContentType+", which the request does not accept")
+ return
+ }
+
+ writer.Header().Set(docker.ContentDigestHeader, docker.SHA256(asset.SHA256).String())
+
+ if request.Method == http.MethodGet {
+ s.recordAssetTraffic(request, models.TrafficDownload, registry.repository, asset)
+ }
+ if !s.writeAsset(writer, request, asset) {
+ dockerError(writer, http.StatusNotFound, docker.ErrorManifestUnknown, "Not found")
+ }
+}
+
+func (s *Server) serveDockerManifestPut(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ document, err := io.ReadAll(io.LimitReader(request.Body, maxManifestBytes+1))
+ if err != nil {
+ dockerError(writer, http.StatusBadRequest, docker.ErrorManifestInvalid, "The manifest could not be read")
+ return
+ }
+ if len(document) > maxManifestBytes {
+ dockerError(writer, http.StatusRequestEntityTooLarge, docker.ErrorManifestInvalid, "The manifest is too large")
+ return
+ }
+
+ manifest, err := docker.ParseManifest(document)
+ if err != nil {
+ dockerError(writer, http.StatusBadRequest, docker.ErrorManifestInvalid, err.Error())
+ return
+ }
+
+ // The digest is of the bytes exactly as they arrived, so it is computed here
+ // rather than taken from the storage layer, which would see them re-encoded.
+ sum := sha256.Sum256(document)
+ digest := docker.SHA256(hex.EncodeToString(sum[:]))
+
+ // A client may PUT by digest, in which case the two have to agree.
+ if registry.route.ByDigest && registry.route.Digest.String() != digest.String() {
+ dockerError(writer, http.StatusBadRequest, docker.ErrorDigestInvalid,
+ "The manifest does not match the digest it was pushed under")
+ return
+ }
+
+ if code, message, ok := s.checkDockerManifestBlobs(registry, manifest); !ok {
+ dockerError(writer, http.StatusNotFound, code, message)
+ return
+ }
+ if !registry.route.ByDigest && !s.checkDockerTagWritable(writer, registry) {
+ return
+ }
+
+ mediaType := manifest.MediaType
+ if declared := request.Header.Get("Content-Type"); docker.IsManifestMediaType(declared) {
+ mediaType = declared
+ }
+
+ var uploadedBy *string
+ if user := currentUser(request); user != nil {
+ uploadedBy = &user.ID
+ }
+ details := uploadDetails{UploadedBy: uploadedBy, ContentType: mediaType}
+
+ stored, err := s.storeUpload(registry.repository, docker.ManifestPath(registry.image, digest), bytes.NewReader(document), details)
+ if err != nil {
+ log.Errorf("storing a docker manifest failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+
+ // The tag copy is what carries the component, so it is written second: a
+ // failure here leaves the content addressable but untagged, which is a state
+ // the registry already has to handle.
+ if !registry.route.ByDigest {
+ tagged, err := s.storeUpload(registry.repository, docker.TagPath(registry.image, registry.route.Tag), bytes.NewReader(document), details)
+ if err != nil {
+ log.Errorf("tagging a docker manifest failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+ stored = tagged
+ }
+
+ if err := s.indexDockerManifest(registry, digest, manifest, int64(len(document))); err != nil {
+ log.Errorf("indexing a docker manifest failed: %v", err)
+ }
+
+ s.recordAssetTraffic(request, models.TrafficUpload, registry.repository, stored)
+
+ writer.Header().Set(docker.ContentDigestHeader, digest.String())
+ writer.Header().Set("Location", "/v2/"+registry.route.Name+"/manifests/"+registry.route.Reference)
+ writer.WriteHeader(http.StatusCreated)
+}
+
+// checkDockerManifestBlobs enforces the spec's rule that everything a manifest
+// references must already be pushed. Nondistributable layers are exempt: they name
+// content a client is expected to fetch from its vendor, so they are never
+// uploaded here.
+func (s *Server) checkDockerManifestBlobs(registry registryRequest, manifest docker.Manifest) (string, string, bool) {
+ for _, reference := range manifest.References() {
+ if docker.IsNondistributable(reference.MediaType) {
+ continue
+ }
+
+ digest, ok := docker.ParseDigest(reference.Digest)
+ if !ok {
+ return docker.ErrorDigestInvalid, reference.Digest + " is not a digest this server stores", false
+ }
+
+ path := docker.BlobPath(digest)
+ code := docker.ErrorManifestBlobUnknown
+ if reference.Kind == docker.ReferenceManifest {
+ path = docker.ManifestPath(registry.image, digest)
+ code = docker.ErrorManifestUnknown
+ }
+
+ if _, err := s.store.FindAsset(registry.repository.ID, path); err != nil {
+ return code, reference.Digest + " has not been pushed to this repository", false
+ }
+ }
+ return "", "", true
+}
+
+// checkDockerTagWritable applies the repository's version policy and, for a tag
+// that already exists, its redeploy setting. Moving a tag is ordinary Docker
+// practice, so a docker repository allows it by default and only an explicitly
+// locked one refuses.
+func (s *Server) checkDockerTagWritable(writer http.ResponseWriter, registry registryRequest) bool {
+ tag := registry.route.Tag
+
+ if !registry.repository.AcceptsPolicy(docker.Prerelease(tag)) {
+ dockerError(writer, http.StatusBadRequest, docker.ErrorTagInvalid,
+ "Repository policy '"+registry.repository.Policy+"' rejects the tag "+tag)
+ return false
+ }
+
+ if registry.repository.AllowRedeploy {
+ return true
+ }
+ if _, err := s.store.FindAsset(registry.repository.ID, docker.TagPath(registry.image, tag)); err == nil {
+ dockerError(writer, http.StatusConflict, docker.ErrorDenied,
+ "The tag "+tag+" already exists and this repository does not allow tags to move")
+ return false
+ }
+ return true
+}
+
+// indexDockerManifest records the parsed manifest and everything it references, so
+// a version's real size and its layer list can be read without reopening any
+// document. It runs after the bytes are stored, because the config blob it reads
+// is looked up the same way a pull would.
+func (s *Server) indexDockerManifest(registry registryRequest, digest docker.Digest, manifest docker.Manifest, size int64) error {
+ namespace, name, ok := registry.namespace()
+ if !ok {
+ return errors.New("server: the image name is not one that maps onto coordinates")
+ }
+
+ record := &models.DockerManifest{
+ RepositoryID: registry.repository.ID,
+ Digest: digest.String(),
+ MediaType: manifest.MediaType,
+ Size: size,
+ Namespace: namespace,
+ Name: name,
+ ConfigDigest: manifest.Config.Digest,
+ LayerCount: len(manifest.Layers),
+ Annotations: encodeJSONMap(manifest.Annotations),
+ }
+ if manifest.Subject != nil {
+ record.Subject = manifest.Subject.Digest
+ }
+
+ references := make([]models.DockerReference, 0, len(manifest.Layers)+len(manifest.Manifests)+1)
+ for _, reference := range manifest.References() {
+ references = append(references, models.DockerReference{
+ RepositoryID: registry.repository.ID,
+ ManifestDigest: digest.String(),
+ ChildDigest: reference.Digest,
+ Kind: reference.Kind,
+ MediaType: reference.MediaType,
+ Size: reference.Size,
+ Position: reference.Position,
+ Platform: reference.Platform,
+ URLs: strings.Join(reference.URLs, "\n"),
+ Annotations: encodeJSONMap(reference.Annotations),
+ })
+ }
+
+ if manifest.IsIndex() {
+ record.TotalSize, record.ImageCreated = s.describeDockerIndex(registry, manifest)
+ } else {
+ record.TotalSize = manifest.DeclaredSize()
+ s.describeDockerImage(registry, manifest, record)
+ }
+
+ return s.store.SaveDockerManifest(record, references)
+}
+
+// describeDockerIndex sums the children rather than the descriptors, because a
+// descriptor's size is the child document's own and says nothing about its layers. A
+// child that has not been indexed yet contributes what it declares.
+//
+// The build time comes from the children too: an index has no config of its own, and
+// reporting nothing when every platform knows when it was built is needlessly bare.
+func (s *Server) describeDockerIndex(registry registryRequest, manifest docker.Manifest) (int64, int64) {
+ var total, created int64
+
+ for _, child := range manifest.Manifests {
+ indexed, err := s.store.DockerManifest(registry.repository.ID, child.Digest)
+ if err != nil {
+ total += child.Size
+ continue
+ }
+ total += indexed.TotalSize
+ created = max(created, indexed.ImageCreated)
+ }
+ return total, created
+}
+
+// describeDockerImage reads the platform and labels out of the config blob. A
+// config that was never pushed is normal in a partially migrated repository, so
+// its absence leaves the fields empty rather than failing the index.
+func (s *Server) describeDockerImage(registry registryRequest, manifest docker.Manifest, record *models.DockerManifest) {
+ digest, ok := docker.ParseDigest(manifest.Config.Digest)
+ if !ok {
+ return
+ }
+
+ asset, err := s.store.FindAsset(registry.repository.ID, docker.BlobPath(digest))
+ if err != nil {
+ return
+ }
+
+ file, _, err := s.blobs.Open(asset.StorageKey)
+ if err != nil {
+ return
+ }
+ defer file.Close()
+
+ document, err := io.ReadAll(io.LimitReader(file, maxManifestBytes))
+ if err != nil {
+ return
+ }
+
+ config, err := docker.ParseConfig(document)
+ if err != nil {
+ log.Warnf("the config blob of %s/%s is unreadable: %v", registry.repository.Name, registry.image, err)
+ return
+ }
+
+ record.Architecture = config.Architecture
+ record.OS = config.OS
+ record.Variant = config.Variant
+ record.ImageCreated = config.Created
+ record.Labels = encodeJSONMap(config.Labels)
+}
+
+func encodeJSONMap(values map[string]string) string {
+ if len(values) == 0 {
+ return ""
+ }
+ encoded, err := json.Marshal(values)
+ if err != nil {
+ return ""
+ }
+ return string(encoded)
+}
+
+// serveDockerManifestDelete removes a tag or an untagged manifest. Blobs are left
+// alone either way: they are shared, so reclaiming them is the sweep's job and not
+// something a single delete can reason about.
+func (s *Server) serveDockerManifestDelete(writer http.ResponseWriter, registry registryRequest) {
+ path := manifestPathFor(registry)
+
+ asset, err := s.store.FindAsset(registry.repository.ID, path)
+ if err != nil {
+ dockerError(writer, http.StatusNotFound, docker.ErrorManifestUnknown, "Not found")
+ return
+ }
+
+ keys, err := s.store.DeleteAsset(registry.repository.ID, path)
+ if err != nil {
+ log.Errorf("deleting a docker manifest failed: %v", err)
+ dockerError(writer, http.StatusInternalServerError, docker.ErrorUnsupported, "Internal server error")
+ return
+ }
+ if err := s.blobs.Delete(keys...); err != nil {
+ log.Errorf("removing a deleted docker manifest failed: %v", err)
+ }
+
+ // Only a digest delete retires the parsed record. Deleting a tag leaves the
+ // manifest reachable by digest, which is what the spec means by untagging.
+ if registry.route.ByDigest {
+ if err := s.store.DeleteDockerManifest(registry.repository.ID, docker.SHA256(asset.SHA256).String()); err != nil {
+ log.Errorf("deleting a docker manifest record failed: %v", err)
+ }
+ }
+
+ writer.WriteHeader(http.StatusAccepted)
+}
diff --git a/internal/server/docker_proxy.go b/internal/server/docker_proxy.go
new file mode 100644
index 0000000..29e8066
--- /dev/null
+++ b/internal/server/docker_proxy.go
@@ -0,0 +1,156 @@
+package server
+
+import (
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/charmbracelet/log"
+
+ "arca/internal/docker"
+ "arca/internal/store/models"
+)
+
+// dockerHubHosts are the registries that expect a single-segment image to be
+// addressed as library/. Nothing else does, and guessing wrongly turns every
+// pull of a private registry's top-level image into a 404, so the rule is keyed on
+// the host rather than applied everywhere.
+var dockerHubHosts = map[string]bool{
+ "registry-1.docker.io": true,
+ "registry.hub.docker.com": true,
+ "index.docker.io": true,
+ "docker.io": true,
+}
+
+// manifestAccept asks for every manifest encoding this server understands. Without
+// it Docker Hub answers with a schema 1 manifest, which is deprecated and which the
+// parser deliberately rejects.
+var manifestAccept = strings.Join([]string{
+ docker.MediaTypeOCIIndex,
+ docker.MediaTypeOCIManifest,
+ docker.MediaTypeDockerList,
+ docker.MediaTypeDockerManifest,
+}, ", ")
+
+// remoteRoot is the registry API's own prefix. Every upstream serves under it, which
+// is the same reason this server mounts /v2 at its host root: a Docker client builds
+// the path itself and there is nowhere else to put it.
+const remoteRoot = "v2/"
+
+// upstreamImage is the name to ask the remote for. It differs from the local name
+// only on Docker Hub, whose official images live under an implicit library/ scope.
+func upstreamImage(repository *models.Repository, image string) string {
+ if strings.Contains(image, "/") {
+ return image
+ }
+
+ parsed, err := url.Parse(repository.RemoteURL)
+ if err != nil || !dockerHubHosts[parsed.Host] {
+ return image
+ }
+ return "library/" + image
+}
+
+// dockerManifestFetch names both ends of a cached manifest. A tag is cached under its
+// own directory so it carries coordinates and shows up as a version, while a digest
+// is cached in the content-addressed store where it can never go stale.
+func (s *Server) dockerManifestFetch(registry registryRequest) proxyFetch {
+ remote := upstreamImage(registry.repository, registry.image)
+
+ fetch := proxyFetch{
+ CachePath: manifestPathFor(registry),
+ RemotePath: remoteRoot + remote + "/manifests/" + registry.route.Reference,
+ Accept: manifestAccept,
+ }
+ fetch.Indexed = func(asset *models.Asset) error {
+ return s.indexCachedDockerManifest(registry, asset)
+ }
+ // A tag request names no digest, so the header can only be filled in once the
+ // cached row is known. The row's SHA256 is that digest by construction.
+ fetch.Headers = func(asset *models.Asset) map[string]string {
+ return map[string]string{docker.ContentDigestHeader: docker.SHA256(asset.SHA256).String()}
+ }
+ return fetch
+}
+
+func (s *Server) dockerBlobFetch(registry registryRequest) proxyFetch {
+ remote := upstreamImage(registry.repository, registry.image)
+
+ digest := registry.route.Digest
+
+ return proxyFetch{
+ CachePath: docker.BlobPath(digest),
+ RemotePath: remoteRoot + remote + "/blobs/" + digest.String(),
+ Limit: s.dockerBlobLimit(),
+ Headers: func(*models.Asset) map[string]string {
+ return map[string]string{docker.ContentDigestHeader: digest.String()}
+ },
+ }
+}
+
+// indexCachedDockerManifest builds the same parsed metadata a push does, so a proxied
+// image is as legible in the UI as a hosted one. A manifest whose blobs have not been
+// pulled yet still indexes: the layer list is what the manifest says, and the sizes
+// come from the document rather than from the files.
+func (s *Server) indexCachedDockerManifest(registry registryRequest, asset *models.Asset) error {
+ file, _, err := s.blobs.Open(asset.StorageKey)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ document, err := io.ReadAll(io.LimitReader(file, maxManifestBytes))
+ if err != nil {
+ return err
+ }
+
+ manifest, err := docker.ParseManifest(document)
+ if err != nil {
+ // An upstream that served something unparseable is worth a line in the log,
+ // but the bytes are cached and servable either way.
+ log.Warnf("the cached manifest %s/%s is not one this server can parse: %v",
+ registry.repository.Name, asset.Path, err)
+ return nil
+ }
+
+ // The layout could only guess the media type from the filename, so the document's
+ // own is written over it now that it has been parsed.
+ if manifest.MediaType != asset.ContentType {
+ if err := s.store.SetAssetContentType(asset.ID, manifest.MediaType); err != nil {
+ return err
+ }
+ }
+
+ return s.indexDockerManifest(registry, docker.SHA256(asset.SHA256), manifest, int64(len(document)))
+}
+
+// serveDockerProxyManifest answers from the cache, refilling it from the remote when
+// the copy is cold or a tag has passed its TTL. Only a tag expires: a digest names
+// exactly one document for all time.
+func (s *Server) serveDockerProxyManifest(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ if !s.dockerProxyReadable(writer, registry) {
+ return
+ }
+
+ s.serveProxyFetch(writer, request, registry.repository, s.dockerManifestFetch(registry))
+}
+
+func (s *Server) serveDockerProxyBlob(writer http.ResponseWriter, request *http.Request, registry registryRequest) {
+ if !s.dockerProxyReadable(writer, registry) {
+ return
+ }
+ s.serveProxyFetch(writer, request, registry.repository, s.dockerBlobFetch(registry))
+}
+
+// dockerProxyReadable refuses a request a proxy cannot serve. A tag list needs the
+// upstream's own catalogue, which this server does not mirror, so it answers from
+// what has been pulled instead and says so rather than pretending to be complete.
+func (s *Server) dockerProxyReadable(writer http.ResponseWriter, registry registryRequest) bool {
+ if registry.repository.RemoteURL == "" {
+ dockerError(writer, http.StatusNotFound, docker.ErrorNameUnknown,
+ "This proxy repository has no remote to fetch from")
+ return false
+ }
+ return true
+}
diff --git a/internal/server/docker_proxy_test.go b/internal/server/docker_proxy_test.go
new file mode 100644
index 0000000..272a93d
--- /dev/null
+++ b/internal/server/docker_proxy_test.go
@@ -0,0 +1,318 @@
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "arca/internal/docker"
+ "arca/internal/store/models"
+)
+
+// fakeRegistry stands in for Docker Hub: it refuses an unauthenticated request with a
+// challenge to a separate token service and only serves content to a bearer token,
+// which is the protocol the proxy client has to implement rather than an option.
+type fakeRegistry struct {
+ server *httptest.Server
+ tokens *httptest.Server
+ manifest []byte
+ config []byte
+ layer []byte
+
+ issued atomic.Int32
+ unauthed atomic.Int32
+ manifests atomic.Int32
+ requested []string
+}
+
+const fakeToken = "issued-bearer-token"
+
+func newFakeRegistry(t *testing.T) *fakeRegistry {
+ t.Helper()
+
+ registry := &fakeRegistry{
+ config: []byte(testImageConfig),
+ layer: bytes.Repeat([]byte("upstream-layer"), 32),
+ }
+ registry.manifest = imageManifestFor(
+ digestOf(registry.config), len(registry.config),
+ map[string]int{digestOf(registry.layer): len(registry.layer)},
+ )
+
+ registry.tokens = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ // The scope names the repository the token is for, which is what makes it
+ // worth caching per scope rather than once per remote.
+ if request.URL.Query().Get("scope") == "" {
+ http.Error(writer, "no scope", http.StatusBadRequest)
+ return
+ }
+ registry.issued.Add(1)
+ writeJSON(writer, http.StatusOK, map[string]any{"token": fakeToken, "expires_in": 300})
+ }))
+ t.Cleanup(registry.tokens.Close)
+
+ registry.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ registry.requested = append(registry.requested, request.URL.Path)
+
+ if request.Header.Get("Authorization") != "Bearer "+fakeToken {
+ registry.unauthed.Add(1)
+ writer.Header().Set("WWW-Authenticate", fmt.Sprintf(
+ `Bearer realm="%s/token",service="fake.registry",scope="repository:library/nginx:pull"`,
+ registry.tokens.URL,
+ ))
+ http.Error(writer, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Matched in full rather than by suffix. A lenient fake here hid a missing
+ // /v2/ prefix that the real Docker Hub answers with a bare 404.
+ switch request.URL.Path {
+ case "/v2/nginx/manifests/1.25", "/v2/nginx/manifests/" + digestOf(registry.manifest):
+ registry.manifests.Add(1)
+ writer.Header().Set("Content-Type", docker.MediaTypeOCIManifest)
+ writer.Write(registry.manifest)
+
+ case "/v2/nginx/blobs/" + digestOf(registry.config):
+ writer.Write(registry.config)
+
+ case "/v2/nginx/blobs/" + digestOf(registry.layer):
+ writer.Write(registry.layer)
+
+ default:
+ http.Error(writer, "not found", http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(registry.server.Close)
+
+ return registry
+}
+
+func (i *testInstance) proxyOf(name, remote string) *registryClient {
+ i.t.Helper()
+
+ expectStatus(i.t, i.api(http.MethodPost, "/api/repositories",
+ `{"name":"`+name+`","format":"docker","type":"proxy","policy":"mixed","remoteUrl":"`+remote+`"}`),
+ http.StatusCreated)
+
+ return ®istryClient{instance: i, repository: name}
+}
+
+func TestDockerProxyPullThrough(t *testing.T) {
+ upstream := newFakeRegistry(t)
+
+ instance := newTestInstance(t)
+ instance.setup()
+ proxy := instance.proxyOf("hub", upstream.server.URL)
+
+ t.Run("a manifest is fetched and cached", func(t *testing.T) {
+ response := proxy.do(http.MethodGet, "/v2/hub/nginx/manifests/1.25", nil, "")
+ body := expectStatus(t, response, http.StatusOK)
+
+ if digestOf([]byte(body)) != digestOf(upstream.manifest) {
+ t.Fatal("the served manifest is not the one upstream holds")
+ }
+ if got := response.Header.Get(docker.ContentDigestHeader); got != digestOf(upstream.manifest) {
+ t.Fatalf("%s = %q, want %q", docker.ContentDigestHeader, got, digestOf(upstream.manifest))
+ }
+ })
+
+ t.Run("the token was obtained rather than the credentials being sent", func(t *testing.T) {
+ if upstream.issued.Load() == 0 {
+ t.Fatal("no token was ever requested from the token service")
+ }
+ if upstream.unauthed.Load() == 0 {
+ t.Fatal("the first request already carried a token, so no challenge was answered")
+ }
+ })
+
+ t.Run("the layer is fetched and cached", func(t *testing.T) {
+ layer := digestOf(upstream.layer)
+
+ body := expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/blobs/"+layer, nil, ""), http.StatusOK)
+ if digestOf([]byte(body)) != layer {
+ t.Fatal("the served layer does not match its digest")
+ }
+ })
+
+ t.Run("the token is reused rather than refetched per request", func(t *testing.T) {
+ issued := upstream.issued.Load()
+
+ expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/blobs/"+digestOf(upstream.config), nil, ""), http.StatusOK)
+
+ if upstream.issued.Load() != issued {
+ t.Fatalf("a cached token was not reused: issued went from %d to %d", issued, upstream.issued.Load())
+ }
+ })
+
+ t.Run("the cached image appears in the UI", func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/hub/artifacts?name=nginx", ""), http.StatusOK)
+
+ if !strings.Contains(body, `"version":"1.25"`) {
+ t.Fatalf("the cached tag is not a version: %s", body)
+ }
+ })
+
+ t.Run("the cached manifest was indexed, so its layers are browsable", func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/hub/docker/manifests?name=nginx&reference=1.25", ""), http.StatusOK)
+
+ var payload struct {
+ LayerCount int `json:"layerCount"`
+ Layers []struct {
+ Digest string `json:"digest"`
+ Stored bool `json:"stored"`
+ } `json:"layers"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the manifest: %v", err)
+ }
+ if payload.LayerCount != 1 || len(payload.Layers) != 1 {
+ t.Fatalf("layers = %v, want one", payload.Layers)
+ }
+ if payload.Layers[0].Digest != digestOf(upstream.layer) {
+ t.Fatalf("layer digest = %q, want %q", payload.Layers[0].Digest, digestOf(upstream.layer))
+ }
+ // It was pulled in an earlier subtest, so it is present locally too.
+ if !payload.Layers[0].Stored {
+ t.Fatal("the pulled layer is not reported as stored")
+ }
+ })
+}
+
+// A blob is immutable by construction, so a proxy must never recheck one. A tag must,
+// because upstream can move it.
+func TestDockerProxyCachesContentForever(t *testing.T) {
+ upstream := newFakeRegistry(t)
+
+ instance := newTestInstance(t)
+ instance.setup()
+ proxy := instance.proxyOf("hub", upstream.server.URL)
+
+ layer := digestOf(upstream.layer)
+ expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/blobs/"+layer, nil, ""), http.StatusOK)
+
+ before := len(upstream.requested)
+ for range 3 {
+ expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/blobs/"+layer, nil, ""), http.StatusOK)
+ }
+
+ if len(upstream.requested) != before {
+ t.Fatalf("the upstream was asked again for an immutable blob: %v", upstream.requested[before:])
+ }
+}
+
+func TestDockerProxyIsReadOnly(t *testing.T) {
+ upstream := newFakeRegistry(t)
+
+ instance := newTestInstance(t)
+ instance.setup()
+ proxy := instance.proxyOf("hub", upstream.server.URL)
+
+ cases := []struct {
+ name string
+ method string
+ path string
+ }{
+ {"opening an upload", http.MethodPost, "/v2/hub/nginx/blobs/uploads/"},
+ {"pushing a manifest", http.MethodPut, "/v2/hub/nginx/manifests/1.0"},
+ {"deleting a manifest", http.MethodDelete, "/v2/hub/nginx/manifests/1.0"},
+ {"deleting a blob", http.MethodDelete, "/v2/hub/nginx/blobs/" + digestOf(upstream.layer)},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ expectStatus(t, proxy.do(testCase.method, testCase.path, nil, ""), http.StatusMethodNotAllowed)
+ })
+ }
+}
+
+func TestDockerProxyReportsAnUpstreamMiss(t *testing.T) {
+ upstream := newFakeRegistry(t)
+
+ instance := newTestInstance(t)
+ instance.setup()
+ proxy := instance.proxyOf("hub", upstream.server.URL)
+
+ expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/manifests/9.9", nil, ""), http.StatusNotFound)
+}
+
+// Docker Hub keeps its official images under an implicit library/ scope, which a
+// single-segment name has to be expanded into. No other registry does, and applying it
+// everywhere would break every private registry's top-level image.
+func TestUpstreamImageNaming(t *testing.T) {
+ cases := []struct {
+ name string
+ remote string
+ image string
+ want string
+ }{
+ {"a bare name on Docker Hub", "https://registry-1.docker.io", "nginx", "library/nginx"},
+ {"a bare name on index.docker.io", "https://index.docker.io", "nginx", "library/nginx"},
+ {"a scoped name on Docker Hub", "https://registry-1.docker.io", "bitnami/nginx", "bitnami/nginx"},
+ {"a bare name elsewhere", "https://ghcr.io", "nginx", "nginx"},
+ {"a bare name on a private registry", "https://registry.example.com", "internal", "internal"},
+ {"a deep name elsewhere", "https://quay.io", "a/b/c", "a/b/c"},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ repository := &models.Repository{RemoteURL: testCase.remote}
+ if got := upstreamImage(repository, testCase.image); got != testCase.want {
+ t.Fatalf("upstreamImage(%q, %q) = %q, want %q", testCase.remote, testCase.image, got, testCase.want)
+ }
+ })
+ }
+}
+
+// A proxy always fetches a manifest before the config blob it points at, so indexing
+// had nothing to read the platform from. It has to be repaired once the blob arrives,
+// or every proxied image reports an unknown architecture for ever.
+func TestDockerProxyBackfillsThePlatform(t *testing.T) {
+ upstream := newFakeRegistry(t)
+
+ instance := newTestInstance(t)
+ instance.setup()
+ proxy := instance.proxyOf("hub", upstream.server.URL)
+
+ expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/manifests/1.25", nil, ""), http.StatusOK)
+
+ read := func() (os, architecture string, created int64) {
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/hub/docker/manifests?name=nginx&reference=1.25", ""), http.StatusOK)
+
+ var payload struct {
+ OS string `json:"os"`
+ Architecture string `json:"architecture"`
+ ImageCreated int64 `json:"imageCreated"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the manifest: %v", err)
+ }
+ return payload.OS, payload.Architecture, payload.ImageCreated
+ }
+
+ t.Run("the platform is unknown while only the manifest is cached", func(t *testing.T) {
+ os, architecture, _ := read()
+ if os != "" || architecture != "" {
+ t.Fatalf("platform = %s/%s, want empty before the config is pulled", os, architecture)
+ }
+ })
+
+ t.Run("pulling the config fills it in", func(t *testing.T) {
+ expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/blobs/"+digestOf(upstream.config), nil, ""), http.StatusOK)
+
+ os, architecture, created := read()
+ if os != "linux" || architecture != "amd64" {
+ t.Fatalf("platform = %s/%s, want linux/amd64", os, architecture)
+ }
+ if created == 0 {
+ t.Fatal("the build time was not read from the config")
+ }
+ })
+}
diff --git a/internal/server/docker_reindex.go b/internal/server/docker_reindex.go
new file mode 100644
index 0000000..4a8f794
--- /dev/null
+++ b/internal/server/docker_reindex.go
@@ -0,0 +1,169 @@
+package server
+
+import (
+ "io"
+ "sort"
+
+ "github.com/charmbracelet/log"
+
+ "arca/internal/docker"
+ "arca/internal/format"
+ "arca/internal/store/models"
+)
+
+// Indexing a manifest reads the config blob it points at, so it can only be complete
+// once that blob is local. Two paths cannot guarantee that at the time the manifest
+// arrives: a proxy fetches the manifest first by definition, and a migration copies in
+// whatever order the source pages. Both are repaired by walking what is stored and
+// indexing it afresh, which is also the only way to fix a repository whose copy was
+// interrupted halfway.
+
+type reindexResult struct {
+ Repository string `json:"repository"`
+ Manifests int `json:"manifests"`
+ Failed int `json:"failed"`
+}
+
+// storedManifest is one manifest found on disk, with the image it belongs to, which the
+// path carries and the document does not.
+type storedManifest struct {
+ image string
+ tag string
+ asset *models.Asset
+ parsed docker.Manifest
+ size int64
+ isIndex bool
+}
+
+func (s *Server) reindexDockerRepository(repository *models.Repository) (reindexResult, error) {
+ result := reindexResult{Repository: repository.Name}
+
+ files, err := s.store.DockerStoredFiles(repository.ID, "")
+ if err != nil {
+ return result, err
+ }
+
+ manifests := make([]storedManifest, 0, len(files))
+ for _, file := range files {
+ found, ok, err := s.readStoredManifest(repository, file.Path)
+ if err != nil {
+ result.Failed++
+ log.Warnf("reading %s/%s failed: %v", repository.Name, file.Path, err)
+ continue
+ }
+ if ok {
+ manifests = append(manifests, found)
+ }
+ }
+
+ // Children before parents: an index's total size is the sum of its children's, so
+ // indexing it first would record only what its descriptors declare. Nested indexes
+ // are legal and vanishingly rare, and would need a second pass they do not get.
+ sort.SliceStable(manifests, func(i, j int) bool {
+ return !manifests[i].isIndex && manifests[j].isIndex
+ })
+
+ for _, manifest := range manifests {
+ if err := s.indexStoredManifest(repository, manifest); err != nil {
+ result.Failed++
+ log.Warnf("indexing %s/%s failed: %v", repository.Name, manifest.asset.Path, err)
+ continue
+ }
+ result.Manifests++
+ }
+
+ if result.Manifests > 0 || result.Failed > 0 {
+ log.Infof("indexed %d manifests of %s, %d failed", result.Manifests, repository.Name, result.Failed)
+ }
+ return result, nil
+}
+
+// readStoredManifest recognises a manifest by its path and parses it. Anything else in
+// the repository is a blob, which reports false rather than an error.
+func (s *Server) readStoredManifest(repository *models.Repository, path string) (storedManifest, bool, error) {
+ found := storedManifest{}
+
+ switch image, tag, ok := docker.ParseTagPath(path); {
+ case ok:
+ found.image, found.tag = image, tag
+ case docker.IsDigestManifestPath(path):
+ found.image = docker.ImageOfManifestPath(path)
+ default:
+ return found, false, nil
+ }
+
+ asset, err := s.store.FindAsset(repository.ID, path)
+ if err != nil {
+ return found, false, err
+ }
+ found.asset = asset
+
+ file, _, err := s.blobs.Open(asset.StorageKey)
+ if err != nil {
+ return found, false, err
+ }
+ defer file.Close()
+
+ document, err := io.ReadAll(io.LimitReader(file, maxManifestBytes))
+ if err != nil {
+ return found, false, err
+ }
+
+ found.parsed, err = docker.ParseManifest(document)
+ if err != nil {
+ // A stored document that is not a manifest is worth reporting once and then
+ // leaving alone: it is servable either way, just not describable.
+ log.Warnf("%s/%s is not a manifest this server can parse: %v", repository.Name, path, err)
+ return found, false, nil
+ }
+
+ found.size = int64(len(document))
+ found.isIndex = found.parsed.IsIndex()
+ return found, true, nil
+}
+
+func (s *Server) indexStoredManifest(repository *models.Repository, manifest storedManifest) error {
+ route := docker.Route{Reference: manifest.tag, Tag: manifest.tag}
+ if manifest.tag == "" {
+ digest := docker.SHA256(manifest.asset.SHA256)
+ route = docker.Route{Reference: digest.String(), Digest: digest, ByDigest: true}
+ }
+
+ registry := registryRequest{repository: repository, image: manifest.image, route: route}
+
+ // The layout could only guess the media type from the filename, which cannot tell
+ // an index from an image manifest. The document knows.
+ if manifest.parsed.MediaType != manifest.asset.ContentType {
+ if err := s.store.SetAssetContentType(manifest.asset.ID, manifest.parsed.MediaType); err != nil {
+ return err
+ }
+ }
+
+ return s.indexDockerManifest(registry, docker.SHA256(manifest.asset.SHA256), manifest.parsed, manifest.size)
+}
+
+// reindexDockerRepositories rebuilds the metadata of every docker repository. It is
+// offered as a maintenance action so a migration that was interrupted, or one run
+// before this server knew how to index, can be repaired without recopying anything.
+func (s *Server) reindexDockerRepositories() ([]reindexResult, error) {
+ repositories, err := s.store.RepositoriesOfFormat(format.Docker)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]reindexResult, 0, len(repositories))
+ for index := range repositories {
+ repository := &repositories[index]
+ if repository.IsGroup() {
+ continue
+ }
+
+ result, err := s.reindexDockerRepository(repository)
+ if err != nil {
+ log.Errorf("reindexing %s failed: %v", repository.Name, err)
+ continue
+ }
+ results = append(results, result)
+ }
+ return results, nil
+}
diff --git a/internal/server/docker_sweep.go b/internal/server/docker_sweep.go
new file mode 100644
index 0000000..7219622
--- /dev/null
+++ b/internal/server/docker_sweep.go
@@ -0,0 +1,210 @@
+package server
+
+import (
+ "time"
+
+ "github.com/charmbracelet/log"
+
+ "arca/internal/docker"
+ "arca/internal/format"
+ "arca/internal/store"
+ "arca/internal/store/models"
+)
+
+// A docker repository is the one format here that cannot reclaim space when
+// something is deleted. Removing a tag leaves its layers behind, correctly, because
+// they are shared: any other tag may still need them. Nothing else ever revisits
+// that decision, so without a sweep a repository grows for as long as it is used.
+//
+// Reachability starts at the tags and follows the reference graph. An untagged
+// manifest is unreachable by definition, which is what makes an index's children
+// survive while a retired tag's exclusive layers do not.
+//
+// Only hosted repositories are swept. A proxy's content is refetchable, and a client
+// that pulled an image by digest cached no tag to be reachable from, so reachability
+// would delete exactly what it is actively using. Proxies age out by last access
+// instead, which purgeIdleCaches already does for every format.
+
+type sweepResult struct {
+ Repository string `json:"repository"`
+ Blobs int `json:"blobs"`
+ Manifests int `json:"manifests"`
+ Bytes int64 `json:"bytes"`
+}
+
+type sweepPlan struct {
+ repository *models.Repository
+ // paths are the assets to remove, blobs and manifests together, since both are
+ // deleted the same way.
+ paths []string
+ // digests are the manifest records whose parsed form goes with them.
+ digests []string
+ result sweepResult
+}
+
+// planDockerSweep decides what is unreachable without deleting anything, so the same
+// walk backs both the preview and the sweep.
+func (s *Server) planDockerSweep(repository *models.Repository) (sweepPlan, error) {
+ plan := sweepPlan{repository: repository, result: sweepResult{Repository: repository.Name}}
+
+ roots, err := s.store.DockerTagRoots(repository.ID)
+ if err != nil {
+ return plan, err
+ }
+
+ reachableManifests := map[string]bool{}
+ reachableBlobs := map[string]bool{}
+
+ frontier := make([]string, 0, len(roots))
+ for _, root := range roots {
+ digest := docker.SHA256(root.SHA256).String()
+ if reachableManifests[digest] {
+ continue
+ }
+ reachableManifests[digest] = true
+ frontier = append(frontier, digest)
+ }
+
+ // Breadth-first through the index children. The visited set is what stops a
+ // manifest that somehow references itself from looping forever.
+ for len(frontier) > 0 {
+ children, blobs, err := s.store.DockerChildrenOf(repository.ID, frontier)
+ if err != nil {
+ return plan, err
+ }
+
+ for _, blob := range blobs {
+ reachableBlobs[blob] = true
+ }
+
+ frontier = frontier[:0]
+ for _, child := range children {
+ if reachableManifests[child] {
+ continue
+ }
+ reachableManifests[child] = true
+ frontier = append(frontier, child)
+ }
+ }
+
+ blobs, err := s.store.DockerStoredFiles(repository.ID, docker.BlobsDirectory+"/")
+ if err != nil {
+ return plan, err
+ }
+ for _, blob := range blobs {
+ if reachableBlobs[docker.SHA256(blob.SHA256).String()] {
+ continue
+ }
+ plan.paths = append(plan.paths, blob.Path)
+ plan.result.Blobs++
+ }
+
+ manifests, err := s.unreachableDockerManifests(repository, reachableManifests)
+ if err != nil {
+ return plan, err
+ }
+ for _, manifest := range manifests {
+ plan.paths = append(plan.paths, manifest.Path)
+ plan.digests = append(plan.digests, docker.SHA256(manifest.SHA256).String())
+ plan.result.Manifests++
+ }
+
+ return plan, nil
+}
+
+// unreachableDockerManifests finds digest-addressed manifests no tag can reach. They
+// live under a directory per image rather than one shared store, so they are found by
+// walking every asset and recognising the shape rather than by one prefix.
+func (s *Server) unreachableDockerManifests(repository *models.Repository, reachable map[string]bool) ([]store.StoredFile, error) {
+ files, err := s.store.DockerStoredFiles(repository.ID, "")
+ if err != nil {
+ return nil, err
+ }
+
+ unreachable := []store.StoredFile{}
+ for _, file := range files {
+ if !docker.IsDigestManifestPath(file.Path) {
+ continue
+ }
+ if reachable[docker.SHA256(file.SHA256).String()] {
+ continue
+ }
+ unreachable = append(unreachable, file)
+ }
+ return unreachable, nil
+}
+
+// applyDockerSweep deletes what the plan named. Rows go before files: a row without
+// a file answers every request with a 404 it can never recover from, while a file
+// without a row is merely unreferenced and the next sweep collects it.
+func (s *Server) applyDockerSweep(plan sweepPlan) (sweepResult, error) {
+ if len(plan.paths) == 0 {
+ return plan.result, nil
+ }
+
+ keys, err := s.store.DeleteAssetsAt(plan.repository.ID, plan.paths)
+ if err != nil {
+ return plan.result, err
+ }
+ if err := s.store.DeleteDockerManifests(plan.repository.ID, plan.digests); err != nil {
+ return plan.result, err
+ }
+
+ usage, err := s.blobs.SizeOf(keys)
+ if err != nil {
+ log.Warnf("measuring swept files of %s failed: %v", plan.repository.Name, err)
+ }
+ plan.result.Bytes = usage
+
+ if err := s.blobs.Delete(keys...); err != nil {
+ return plan.result, err
+ }
+
+ log.Infof("swept %d unreferenced blobs and %d untagged manifests from %s",
+ plan.result.Blobs, plan.result.Manifests, plan.repository.Name)
+
+ return plan.result, nil
+}
+
+// sweepDockerRepositories runs the sweep over every docker repository. A failure on
+// one is logged and the rest carry on, because a repository whose graph cannot be
+// read should not stop the others reclaiming their space.
+func (s *Server) sweepDockerRepositories(apply bool) ([]sweepResult, error) {
+ repositories, err := s.store.RepositoriesOfFormat(format.Docker)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]sweepResult, 0, len(repositories))
+ for index := range repositories {
+ repository := &repositories[index]
+ if repository.IsProxy() || repository.IsGroup() {
+ continue
+ }
+
+ plan, err := s.planDockerSweep(repository)
+ if err != nil {
+ log.Errorf("planning the %s sweep failed: %v", repository.Name, err)
+ continue
+ }
+ if !apply {
+ results = append(results, plan.result)
+ continue
+ }
+
+ result, err := s.applyDockerSweep(plan)
+ if err != nil {
+ log.Errorf("sweeping %s failed: %v", repository.Name, err)
+ }
+ results = append(results, result)
+ }
+ return results, nil
+}
+
+// purgeUnreachableDockerContent is the periodic half. It runs alongside the proxy
+// cache eviction, which is the same job for a different reason.
+func (s *Server) purgeUnreachableDockerContent(time.Time) {
+ if _, err := s.sweepDockerRepositories(true); err != nil {
+ log.Errorf("sweeping docker repositories failed: %v", err)
+ }
+}
diff --git a/internal/server/docker_sweep_test.go b/internal/server/docker_sweep_test.go
new file mode 100644
index 0000000..9bd0f87
--- /dev/null
+++ b/internal/server/docker_sweep_test.go
@@ -0,0 +1,368 @@
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "arca/internal/docker"
+)
+
+type sweepReport struct {
+ Repositories []struct {
+ Repository string `json:"repository"`
+ Blobs int `json:"blobs"`
+ Manifests int `json:"manifests"`
+ Bytes int64 `json:"bytes"`
+ } `json:"repositories"`
+}
+
+func (i *testInstance) sweep(method string) sweepReport {
+ i.t.Helper()
+
+ body := expectStatus(i.t, i.api(method, "/api/admin/docker/sweep", ""), http.StatusOK)
+
+ var report sweepReport
+ if err := json.Unmarshal([]byte(body), &report); err != nil {
+ i.t.Fatalf("decoding the sweep report: %v", err)
+ }
+ return report
+}
+
+func (i *testInstance) sweepOf(report sweepReport, repository string) (blobs, manifests int) {
+ i.t.Helper()
+
+ for _, entry := range report.Repositories {
+ if entry.Repository == repository {
+ return entry.Blobs, entry.Manifests
+ }
+ }
+ i.t.Fatalf("%q is missing from the sweep report", repository)
+ return 0, 0
+}
+
+// A tagged image must survive a sweep untouched. This is the case that would be
+// catastrophic to get wrong, so it is asserted before anything about reclaiming.
+func TestDockerSweepKeepsTaggedImages(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ manifestDigest, layerDigest := registry.pushImage("team/api", "1.0")
+
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodPost), "images")
+ if blobs != 0 || manifests != 0 {
+ t.Fatalf("a sweep removed %d blobs and %d manifests from a fully tagged repository", blobs, manifests)
+ }
+
+ cases := []struct {
+ name string
+ path string
+ }{
+ {"the tag", "/v2/images/team/api/manifests/1.0"},
+ {"the manifest by digest", "/v2/images/team/api/manifests/" + manifestDigest},
+ {"the layer", "/v2/images/team/api/blobs/" + layerDigest},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodGet, testCase.path, nil, ""), http.StatusOK)
+ })
+ }
+}
+
+func TestDockerSweepReclaimsAnUntaggedImage(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ // Two tags with layers of their own, so removing one leaves content that only it
+ // referenced and content the survivor still needs.
+ _, keptLayer := registry.pushImage("team/api", "1.0")
+
+ config := []byte(testImageConfig)
+ configDigest := digestOf(config)
+ retiredLayer := registry.pushBlob("team/api", bytes.Repeat([]byte("retired"), 32))
+ retired := imageManifestFor(configDigest, len(config), map[string]int{retiredLayer: 224})
+ expectStatus(t, registry.pushManifest("team/api", "0.9", retired), http.StatusCreated)
+
+ // Deleting the tag leaves the manifest and its exclusive layer behind, which is
+ // correct on its own and is exactly what the sweep exists to finish.
+ expectStatus(t, registry.do(http.MethodDelete, "/v2/images/team/api/manifests/0.9", nil, ""), http.StatusAccepted)
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/team/api/blobs/"+retiredLayer, nil, ""), http.StatusOK)
+
+ t.Run("the preview reports what would go without removing it", func(t *testing.T) {
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodGet), "images")
+ if blobs != 1 || manifests != 1 {
+ t.Fatalf("preview reported %d blobs and %d manifests, want 1 and 1", blobs, manifests)
+ }
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/team/api/blobs/"+retiredLayer, nil, ""), http.StatusOK)
+ })
+
+ t.Run("the sweep removes them", func(t *testing.T) {
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodPost), "images")
+ if blobs != 1 || manifests != 1 {
+ t.Fatalf("the sweep removed %d blobs and %d manifests, want 1 and 1", blobs, manifests)
+ }
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/team/api/blobs/"+retiredLayer, nil, ""), http.StatusNotFound)
+ })
+
+ t.Run("the surviving tag is intact", func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/team/api/manifests/1.0", nil, ""), http.StatusOK)
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/team/api/blobs/"+keptLayer, nil, ""), http.StatusOK)
+ })
+
+ t.Run("a second sweep finds nothing left", func(t *testing.T) {
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodPost), "images")
+ if blobs != 0 || manifests != 0 {
+ t.Fatalf("a repeat sweep removed %d blobs and %d manifests, want none", blobs, manifests)
+ }
+ })
+}
+
+// A layer shared by a surviving tag must not be reclaimed when the tag that also used
+// it goes. This is the whole reason the reference table exists.
+func TestDockerSweepKeepsSharedLayers(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ _, layer := registry.pushImage("shared/app", "1.0")
+
+ config := []byte(testImageConfig)
+ second := withAnnotation(
+ imageManifestFor(digestOf(config), len(config), map[string]int{layer: 704}),
+ "org.opencontainers.image.revision", "second",
+ )
+ expectStatus(t, registry.pushManifest("shared/app", "1.1", second), http.StatusCreated)
+
+ expectStatus(t, registry.do(http.MethodDelete, "/v2/images/shared/app/manifests/1.0", nil, ""), http.StatusAccepted)
+
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodPost), "images")
+
+ // The retired manifest goes, but its layer stays because 1.1 still needs it.
+ if manifests != 1 {
+ t.Fatalf("the sweep removed %d manifests, want the one that lost its tag", manifests)
+ }
+ if blobs != 0 {
+ t.Fatalf("the sweep removed %d blobs, want none since the survivor shares them", blobs)
+ }
+
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/shared/app/blobs/"+layer, nil, ""), http.StatusOK)
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/shared/app/manifests/1.1", nil, ""), http.StatusOK)
+}
+
+// An index's children carry no tag of their own, so a sweep that only looked at tags
+// would delete every platform of every multi-arch image.
+func TestDockerSweepKeepsIndexChildren(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ config := []byte(testImageConfig)
+ configDigest := registry.pushBlob("multi/app", config)
+
+ children := []map[string]any{}
+ layers := []string{}
+ for index := range 2 {
+ layer := bytes.Repeat([]byte{byte('a' + index)}, 128)
+ layerDigest := registry.pushBlob("multi/app", layer)
+ layers = append(layers, layerDigest)
+
+ child := imageManifestFor(configDigest, len(config), map[string]int{layerDigest: len(layer)})
+ expectStatus(t, registry.pushManifest("multi/app", digestOf(child), child), http.StatusCreated)
+
+ children = append(children, map[string]any{
+ "mediaType": docker.MediaTypeOCIManifest,
+ "digest": digestOf(child),
+ "size": len(child),
+ "platform": map[string]any{"os": "linux", "architecture": []string{"amd64", "arm64"}[index]},
+ })
+ }
+
+ index, err := json.Marshal(map[string]any{
+ "schemaVersion": 2,
+ "mediaType": docker.MediaTypeOCIIndex,
+ "manifests": children,
+ })
+ if err != nil {
+ t.Fatalf("building the index: %v", err)
+ }
+ expectStatus(t, registry.do(http.MethodPut, "/v2/images/multi/app/manifests/1.0", index, docker.MediaTypeOCIIndex), http.StatusCreated)
+
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodPost), "images")
+ if blobs != 0 || manifests != 0 {
+ t.Fatalf("the sweep removed %d blobs and %d manifests reachable only through an index", blobs, manifests)
+ }
+
+ for _, layer := range layers {
+ t.Run("layer "+layer[7:19], func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/multi/app/blobs/"+layer, nil, ""), http.StatusOK)
+ })
+ }
+ for _, child := range children {
+ digest := child["digest"].(string)
+ t.Run("child "+digest[7:19], func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/multi/app/manifests/"+digest, nil, ""), http.StatusOK)
+ })
+ }
+
+ t.Run("removing the index tag retires the whole tree", func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodDelete, "/v2/images/multi/app/manifests/1.0", nil, ""), http.StatusAccepted)
+
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodPost), "images")
+ // Two children plus the index itself, and the config plus both layers.
+ if manifests != 3 {
+ t.Fatalf("the sweep removed %d manifests, want 3", manifests)
+ }
+ if blobs != 3 {
+ t.Fatalf("the sweep removed %d blobs, want 3", blobs)
+ }
+ })
+}
+
+// An upload session leaves a file that is not an asset. The sweep walks assets, so it
+// must not confuse itself over one, and the session sweep is what collects them.
+func TestDockerSweepIgnoresOpenUploads(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+ registry.pushImage("app", "1.0")
+
+ response := registry.do(http.MethodPost, "/v2/images/app/blobs/uploads/", nil, "")
+ expectStatus(t, response, http.StatusAccepted)
+ location := response.Header.Get("Location")
+ expectStatus(t, registry.do(http.MethodPatch, location, []byte("half a layer"), blobContentType), http.StatusAccepted)
+
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodPost), "images")
+ if blobs != 0 || manifests != 0 {
+ t.Fatalf("the sweep removed %d blobs and %d manifests with an upload open", blobs, manifests)
+ }
+
+ // The session is still usable, which is the point: a sweep is not allowed to
+ // interfere with a push in flight.
+ expectStatus(t, registry.do(http.MethodGet, location, nil, ""), http.StatusNoContent)
+}
+
+func TestDockerSweepRequiresAnAdministrator(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ for _, method := range []string{http.MethodGet, http.MethodPost} {
+ t.Run(method, func(t *testing.T) {
+ expectStatus(t, instance.doAnonymously(instance.request(method, "/api/admin/docker/sweep", "")), http.StatusUnauthorized)
+ })
+ }
+}
+
+// A proxy's content is refetchable, and a pull by digest caches no tag for
+// reachability to start from, so a reachability sweep would delete exactly what is in
+// use. Proxies age out by last access instead.
+func TestDockerSweepSkipsProxies(t *testing.T) {
+ upstream := newFakeRegistry(t)
+
+ instance := newTestInstance(t)
+ instance.setup()
+ proxy := instance.proxyOf("hub", upstream.server.URL)
+
+ // Pulled by digest only, so nothing here is reachable from a tag.
+ layer := digestOf(upstream.layer)
+ expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/blobs/"+layer, nil, ""), http.StatusOK)
+
+ report := instance.sweep(http.MethodPost)
+ for _, entry := range report.Repositories {
+ if entry.Repository == "hub" {
+ t.Fatalf("the sweep considered a proxy repository: %+v", entry)
+ }
+ }
+
+ expectStatus(t, proxy.do(http.MethodGet, "/v2/hub/nginx/blobs/"+layer, nil, ""), http.StatusOK)
+}
+
+// Rebuilding reads every stored manifest afresh. It is how a repository copied before
+// this server knew how to index, or one whose migration stopped halfway, is repaired
+// without recopying a byte.
+func TestDockerReindexRebuildsMetadata(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ manifestDigest, _ := registry.pushImage("team/api", "1.0")
+
+ // Wiping the parsed form leaves the bytes in place, which is the state a copy that
+ // never settled leaves behind.
+ if err := instance.app.store.DeleteDockerManifest(dockerRepositoryID(t, instance, "images"), manifestDigest); err != nil {
+ t.Fatalf("clearing the index: %v", err)
+ }
+ expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/images/docker/manifests?namespace=team&name=api&reference=1.0", ""), http.StatusNotFound)
+
+ body := expectStatus(t, instance.api(http.MethodPost, "/api/admin/docker/reindex", ""), http.StatusOK)
+
+ var report struct {
+ Repositories []struct {
+ Repository string `json:"repository"`
+ Manifests int `json:"manifests"`
+ Failed int `json:"failed"`
+ } `json:"repositories"`
+ }
+ if err := json.Unmarshal([]byte(body), &report); err != nil {
+ t.Fatalf("decoding the report: %v", err)
+ }
+
+ found := false
+ for _, entry := range report.Repositories {
+ if entry.Repository != "images" {
+ continue
+ }
+ found = true
+ // The tag copy and the digest copy are both manifests on disk.
+ if entry.Manifests != 2 || entry.Failed != 0 {
+ t.Fatalf("reindexed %d manifests, %d failed", entry.Manifests, entry.Failed)
+ }
+ }
+ if !found {
+ t.Fatalf("images is missing from the report: %s", body)
+ }
+
+ t.Run("the platform is back", func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/images/docker/manifests?namespace=team&name=api&reference=1.0", ""), http.StatusOK)
+
+ var payload struct {
+ Architecture string `json:"architecture"`
+ LayerCount int `json:"layerCount"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the manifest: %v", err)
+ }
+ if payload.Architecture != "amd64" || payload.LayerCount != 1 {
+ t.Fatalf("architecture = %q, layers = %d", payload.Architecture, payload.LayerCount)
+ }
+ })
+
+ t.Run("a sweep after rebuilding still keeps the tagged image", func(t *testing.T) {
+ blobs, manifests := instance.sweepOf(instance.sweep(http.MethodPost), "images")
+ if blobs != 0 || manifests != 0 {
+ t.Fatalf("the sweep removed %d blobs and %d manifests after a rebuild", blobs, manifests)
+ }
+ })
+}
+
+func dockerRepositoryID(t *testing.T, instance *testInstance, name string) string {
+ t.Helper()
+
+ repository, err := instance.app.store.RepositoryByName(name)
+ if err != nil {
+ t.Fatalf("looking up %s: %v", name, err)
+ }
+ return repository.ID
+}
+
+func TestDockerReindexRequiresAnAdministrator(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.doAnonymously(instance.request(http.MethodPost, "/api/admin/docker/reindex", "")), http.StatusUnauthorized)
+}
diff --git a/internal/server/docker_test.go b/internal/server/docker_test.go
new file mode 100644
index 0000000..250ec45
--- /dev/null
+++ b/internal/server/docker_test.go
@@ -0,0 +1,824 @@
+package server
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "arca/internal/docker"
+)
+
+// registryClient drives the registry API the way a Docker client does, so a test
+// exercises the wire protocol rather than the handlers directly.
+type registryClient struct {
+ instance *testInstance
+ repository string
+}
+
+func (i *testInstance) registry(repository string) *registryClient {
+ i.t.Helper()
+
+ expectStatus(i.t, i.api(http.MethodPost, "/api/repositories",
+ `{"name":"`+repository+`","format":"docker","policy":"mixed","allowRedeploy":true}`), http.StatusCreated)
+
+ return ®istryClient{instance: i, repository: repository}
+}
+
+func (c *registryClient) do(method, path string, body []byte, contentType string) *http.Response {
+ c.instance.t.Helper()
+
+ var reader io.Reader
+ if body != nil {
+ reader = bytes.NewReader(body)
+ }
+
+ request, err := http.NewRequest(method, c.instance.server.URL+path, reader)
+ if err != nil {
+ c.instance.t.Fatalf("building a registry request: %v", err)
+ }
+ if contentType != "" {
+ request.Header.Set("Content-Type", contentType)
+ }
+ request.SetBasicAuth(administratorEmail, administratorPassword)
+
+ return c.instance.do(request)
+}
+
+func (c *registryClient) name(image string) string { return c.repository + "/" + image }
+
+func digestOf(payload []byte) string {
+ sum := sha256.Sum256(payload)
+ return "sha256:" + hex.EncodeToString(sum[:])
+}
+
+// pushBlob runs the three-request chunked push a Docker client uses, splitting the
+// payload so the PATCH path is exercised rather than only the monolithic one.
+func (c *registryClient) pushBlob(image string, payload []byte) string {
+ c.instance.t.Helper()
+ t := c.instance.t
+
+ response := c.do(http.MethodPost, "/v2/"+c.name(image)+"/blobs/uploads/", nil, "")
+ expectStatus(t, response, http.StatusAccepted)
+
+ location := response.Header.Get("Location")
+ if location == "" {
+ t.Fatal("the upload did not report a Location")
+ }
+ if got := response.Header.Get("Range"); got != "0-0" {
+ t.Fatalf("a fresh session reported Range %q, want 0-0", got)
+ }
+
+ split := len(payload) / 2
+ response = c.do(http.MethodPatch, location, payload[:split], blobContentType)
+ expectStatus(t, response, http.StatusAccepted)
+
+ if got, want := response.Header.Get("Range"), fmt.Sprintf("0-%d", split-1); got != want {
+ t.Fatalf("after one chunk Range = %q, want %q", got, want)
+ }
+
+ digest := digestOf(payload)
+ response = c.do(http.MethodPut, location+"?digest="+digest, payload[split:], blobContentType)
+ expectStatus(t, response, http.StatusCreated)
+
+ if got := response.Header.Get(docker.ContentDigestHeader); got != digest {
+ t.Fatalf("commit reported digest %q, want %q", got, digest)
+ }
+ return digest
+}
+
+func (c *registryClient) pushManifest(image, reference string, manifest []byte) *http.Response {
+ return c.do(http.MethodPut, "/v2/"+c.name(image)+"/manifests/"+reference, manifest, docker.MediaTypeOCIManifest)
+}
+
+func imageManifestFor(configDigest string, configSize int, layers map[string]int) []byte {
+ descriptors := make([]map[string]any, 0, len(layers))
+ for digest, size := range layers {
+ descriptors = append(descriptors, map[string]any{
+ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
+ "digest": digest,
+ "size": size,
+ })
+ }
+
+ document, err := json.Marshal(map[string]any{
+ "schemaVersion": 2,
+ "mediaType": docker.MediaTypeOCIManifest,
+ "config": map[string]any{
+ "mediaType": docker.MediaTypeOCIConfig,
+ "digest": configDigest,
+ "size": configSize,
+ },
+ "layers": descriptors,
+ })
+ if err != nil {
+ panic(err)
+ }
+ return document
+}
+
+// withAnnotation makes a manifest distinct without changing what it references,
+// which is how a test builds two tags that genuinely share one layer.
+func withAnnotation(document []byte, key, value string) []byte {
+ var manifest map[string]any
+ if err := json.Unmarshal(document, &manifest); err != nil {
+ panic(err)
+ }
+ manifest["annotations"] = map[string]string{key: value}
+
+ annotated, err := json.Marshal(manifest)
+ if err != nil {
+ panic(err)
+ }
+ return annotated
+}
+
+const testImageConfig = `{"architecture":"amd64","os":"linux","created":"2026-07-30T10:11:12Z",` +
+ `"config":{"Entrypoint":["/bin/app"],"Labels":{"owner":"platform"}},` +
+ `"rootfs":{"type":"layers","diff_ids":[]}}`
+
+// pushImage stores a config, a layer and a manifest, which is the whole of what
+// "docker push" does for a single-platform image.
+func (c *registryClient) pushImage(image, tag string) (manifestDigest string, layerDigest string) {
+ c.instance.t.Helper()
+
+ config := []byte(testImageConfig)
+ layer := bytes.Repeat([]byte("layer-bytes"), 64)
+
+ configDigest := c.pushBlob(image, config)
+ layerDigest = c.pushBlob(image, layer)
+
+ manifest := imageManifestFor(configDigest, len(config), map[string]int{layerDigest: len(layer)})
+ response := c.pushManifest(image, tag, manifest)
+ expectStatus(c.instance.t, response, http.StatusCreated)
+
+ return digestOf(manifest), layerDigest
+}
+
+func TestDockerVersionCheck(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ t.Run("an anonymous client is challenged when nothing is public", func(t *testing.T) {
+ response := instance.doAnonymously(instance.request(http.MethodGet, "/v2/", ""))
+ expectStatus(t, response, http.StatusUnauthorized)
+
+ if challenge := response.Header.Get("WWW-Authenticate"); !strings.HasPrefix(challenge, "Basic ") {
+ t.Fatalf("WWW-Authenticate = %q, want a Basic challenge so docker login works", challenge)
+ }
+ })
+
+ t.Run("an authenticated client is accepted", func(t *testing.T) {
+ request := instance.request(http.MethodGet, "/v2/", "")
+ request.SetBasicAuth(administratorEmail, administratorPassword)
+
+ response := instance.do(request)
+ expectStatus(t, response, http.StatusOK)
+
+ if got := response.Header.Get(docker.APIVersionHeader); got != docker.APIVersion {
+ t.Fatalf("%s = %q, want %q", docker.APIVersionHeader, got, docker.APIVersion)
+ }
+ })
+
+ t.Run("an anonymous client is accepted once a docker repository is public", func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories",
+ `{"name":"public-images","format":"docker","visibility":"public"}`), http.StatusCreated)
+
+ expectStatus(t, instance.doAnonymously(instance.request(http.MethodGet, "/v2/", "")), http.StatusOK)
+ })
+}
+
+func TestDockerPushAndPull(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ manifestDigest, layerDigest := registry.pushImage("team/api", "1.4.0")
+
+ t.Run("the manifest is pullable by tag", func(t *testing.T) {
+ response := registry.do(http.MethodGet, "/v2/images/team/api/manifests/1.4.0", nil, "")
+ body := expectStatus(t, response, http.StatusOK)
+
+ if got := response.Header.Get(docker.ContentDigestHeader); got != manifestDigest {
+ t.Fatalf("%s = %q, want %q", docker.ContentDigestHeader, got, manifestDigest)
+ }
+ if got := response.Header.Get("Content-Type"); got != docker.MediaTypeOCIManifest {
+ t.Fatalf("Content-Type = %q, want %q", got, docker.MediaTypeOCIManifest)
+ }
+ if !strings.Contains(body, layerDigest) {
+ t.Fatalf("the manifest does not mention its layer: %s", body)
+ }
+ })
+
+ t.Run("the manifest is pullable by digest", func(t *testing.T) {
+ response := registry.do(http.MethodGet, "/v2/images/team/api/manifests/"+manifestDigest, nil, "")
+ expectStatus(t, response, http.StatusOK)
+ })
+
+ t.Run("a HEAD reports the digest without a body", func(t *testing.T) {
+ response := registry.do(http.MethodHead, "/v2/images/team/api/manifests/1.4.0", nil, "")
+ body := expectStatus(t, response, http.StatusOK)
+
+ if body != "" {
+ t.Fatalf("HEAD returned a body: %q", body)
+ }
+ if got := response.Header.Get(docker.ContentDigestHeader); got != manifestDigest {
+ t.Fatalf("%s = %q, want %q", docker.ContentDigestHeader, got, manifestDigest)
+ }
+ })
+
+ t.Run("the layer is pullable", func(t *testing.T) {
+ response := registry.do(http.MethodGet, "/v2/images/team/api/blobs/"+layerDigest, nil, "")
+ body := expectStatus(t, response, http.StatusOK)
+
+ if digestOf([]byte(body)) != layerDigest {
+ t.Fatalf("the served layer hashed to %s, want %s", digestOf([]byte(body)), layerDigest)
+ }
+ })
+
+ t.Run("the tag is listed", func(t *testing.T) {
+ body := expectStatus(t, registry.do(http.MethodGet, "/v2/images/team/api/tags/list", nil, ""), http.StatusOK)
+
+ var payload struct {
+ Name string `json:"name"`
+ Tags []string `json:"tags"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the tag list: %v", err)
+ }
+ if payload.Name != "images/team/api" {
+ t.Fatalf("name = %q, want %q", payload.Name, "images/team/api")
+ }
+ if len(payload.Tags) != 1 || payload.Tags[0] != "1.4.0" {
+ t.Fatalf("tags = %v, want [1.4.0]", payload.Tags)
+ }
+ })
+
+ t.Run("the tag became a component the rest of the UI can see", func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/images/artifacts?namespace=team&name=api", ""), http.StatusOK)
+
+ if !strings.Contains(body, `"version":"1.4.0"`) {
+ t.Fatalf("the tag is not a version: %s", body)
+ }
+ if !strings.Contains(body, `"format":"docker"`) {
+ t.Fatalf("the format was not recorded: %s", body)
+ }
+ })
+}
+
+func TestDockerManifestRequiresItsBlobs(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ config := []byte(testImageConfig)
+ configDigest := registry.pushBlob("solo", config)
+
+ missing := digestOf([]byte("never pushed"))
+ manifest := imageManifestFor(configDigest, len(config), map[string]int{missing: 11})
+
+ body := expectStatus(t, registry.pushManifest("solo", "1.0", manifest), http.StatusNotFound)
+ if !strings.Contains(body, docker.ErrorManifestBlobUnknown) {
+ t.Fatalf("expected %s, got %s", docker.ErrorManifestBlobUnknown, body)
+ }
+}
+
+func TestDockerBlobUploadRejections(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ payload := []byte("some layer content")
+
+ t.Run("a commit whose digest does not match the bytes is refused", func(t *testing.T) {
+ response := registry.do(http.MethodPost, "/v2/images/app/blobs/uploads/", nil, "")
+ expectStatus(t, response, http.StatusAccepted)
+ location := response.Header.Get("Location")
+
+ wrong := digestOf([]byte("different content"))
+ body := expectStatus(t, registry.do(http.MethodPut, location+"?digest="+wrong, payload, blobContentType), http.StatusBadRequest)
+
+ if !strings.Contains(body, docker.ErrorDigestInvalid) {
+ t.Fatalf("expected %s, got %s", docker.ErrorDigestInvalid, body)
+ }
+ })
+
+ t.Run("a chunk that does not continue the session is refused", func(t *testing.T) {
+ response := registry.do(http.MethodPost, "/v2/images/app/blobs/uploads/", nil, "")
+ expectStatus(t, response, http.StatusAccepted)
+ location := response.Header.Get("Location")
+
+ request, err := http.NewRequest(http.MethodPatch, instance.server.URL+location, bytes.NewReader(payload))
+ if err != nil {
+ t.Fatalf("building a PATCH: %v", err)
+ }
+ request.Header.Set("Content-Range", "500-600")
+ request.SetBasicAuth(administratorEmail, administratorPassword)
+
+ expectStatus(t, instance.do(request), http.StatusRequestedRangeNotSatisfiable)
+ })
+
+ t.Run("an unknown session is not found", func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodPatch, "/v2/images/app/blobs/uploads/nosuchsession", payload, blobContentType), http.StatusNotFound)
+ })
+
+ t.Run("a monolithic push in one POST is accepted", func(t *testing.T) {
+ digest := digestOf(payload)
+ response := registry.do(http.MethodPost, "/v2/images/app/blobs/uploads/?digest="+digest, payload, blobContentType)
+ expectStatus(t, response, http.StatusCreated)
+
+ if got := response.Header.Get(docker.ContentDigestHeader); got != digest {
+ t.Fatalf("%s = %q, want %q", docker.ContentDigestHeader, got, digest)
+ }
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/app/blobs/"+digest, nil, ""), http.StatusOK)
+ })
+}
+
+func TestDockerLayersAreSharedBetweenTags(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ first, layer := registry.pushImage("shared/app", "1.0")
+
+ // The same layer is referenced by a second tag through an otherwise identical
+ // manifest, which is what makes a per-version size need the reference table
+ // rather than the assets under the tag.
+ config := []byte(testImageConfig)
+ configDigest := digestOf(config)
+ second := withAnnotation(
+ imageManifestFor(configDigest, len(config), map[string]int{layer: 704}),
+ "org.opencontainers.image.revision", "second",
+ )
+ expectStatus(t, registry.pushManifest("shared/app", "1.1", second), http.StatusCreated)
+
+ if first == digestOf(second) {
+ t.Fatal("the two manifests are identical, so this proves nothing about sharing")
+ }
+
+ body := expectStatus(t, registry.do(http.MethodGet, "/v2/images/shared/app/tags/list", nil, ""), http.StatusOK)
+ for _, tag := range []string{"1.0", "1.1"} {
+ if !strings.Contains(body, `"`+tag+`"`) {
+ t.Fatalf("tag %s is missing from %s", tag, body)
+ }
+ }
+
+ t.Run("deleting one tag leaves the layer for the other", func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodDelete, "/v2/images/shared/app/manifests/1.0", nil, ""), http.StatusAccepted)
+
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/shared/app/manifests/1.0", nil, ""), http.StatusNotFound)
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/shared/app/blobs/"+layer, nil, ""), http.StatusOK)
+ expectStatus(t, registry.do(http.MethodGet, "/v2/images/shared/app/manifests/1.1", nil, ""), http.StatusOK)
+ })
+}
+
+func TestDockerRepositoryResolution(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+ registry.pushImage("nginx", "1.25")
+
+ cases := []struct {
+ name string
+ path string
+ status int
+ }{
+ {"the leading segment names the repository", "/v2/images/nginx/manifests/1.25", http.StatusOK},
+ {"an unknown repository is not found", "/v2/nowhere/nginx/manifests/1.25", http.StatusNotFound},
+ {"an unknown image in a known repository is not found", "/v2/images/absent/manifests/1.25", http.StatusNotFound},
+ {"an unknown tag is not found", "/v2/images/nginx/manifests/9.9", http.StatusNotFound},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodGet, testCase.path, nil, ""), testCase.status)
+ })
+ }
+
+ t.Run("a bare image resolves through the default repository", func(t *testing.T) {
+ if err := instance.app.store.SetSetting("docker_repository", "images"); err != nil {
+ t.Fatalf("setting the default repository: %v", err)
+ }
+ expectStatus(t, registry.do(http.MethodGet, "/v2/nginx/manifests/1.25", nil, ""), http.StatusOK)
+ })
+}
+
+func TestDockerCatalogListsPrefixedImages(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ registry := instance.registry("images")
+ registry.pushImage("team/api", "1.0")
+ registry.pushImage("nginx", "1.25")
+
+ body := expectStatus(t, registry.do(http.MethodGet, "/v2/_catalog", nil, ""), http.StatusOK)
+
+ var payload struct {
+ Repositories []string `json:"repositories"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the catalog: %v", err)
+ }
+
+ // Every entry carries the repository prefix, because without it a client
+ // could not turn a catalog entry back into something it can pull.
+ want := map[string]bool{"images/team/api": false, "images/nginx": false}
+ for _, name := range payload.Repositories {
+ if _, ok := want[name]; ok {
+ want[name] = true
+ }
+ }
+ for name, found := range want {
+ if !found {
+ t.Fatalf("%q is missing from the catalog: %v", name, payload.Repositories)
+ }
+ }
+}
+
+func TestDockerPermissions(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("private-images")
+ registry.pushImage("app", "1.0")
+
+ t.Run("an anonymous pull from a private repository is challenged", func(t *testing.T) {
+ response := instance.doAnonymously(instance.request(http.MethodGet, "/v2/private-images/app/manifests/1.0", ""))
+ expectStatus(t, response, http.StatusUnauthorized)
+
+ if challenge := response.Header.Get("WWW-Authenticate"); !strings.HasPrefix(challenge, "Basic ") {
+ t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", challenge)
+ }
+ })
+
+ t.Run("a proxy repository refuses writes", func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories",
+ `{"name":"hub","format":"docker","type":"proxy","remoteUrl":"https://registry-1.docker.io"}`), http.StatusCreated)
+
+ proxy := ®istryClient{instance: instance, repository: "hub"}
+ expectStatus(t, proxy.do(http.MethodPost, "/v2/hub/nginx/blobs/uploads/", nil, ""), http.StatusMethodNotAllowed)
+ })
+}
+
+func TestDockerTagPolicyAndImmutability(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories",
+ `{"name":"releases","format":"docker","policy":"release","allowRedeploy":false}`), http.StatusCreated)
+
+ registry := ®istryClient{instance: instance, repository: "releases"}
+
+ config := []byte(testImageConfig)
+ configDigest := registry.pushBlob("app", config)
+ manifest := imageManifestFor(configDigest, len(config), nil)
+
+ t.Run("a release tag is accepted", func(t *testing.T) {
+ expectStatus(t, registry.pushManifest("app", "1.0.0", manifest), http.StatusCreated)
+ })
+
+ t.Run("a prerelease tag is rejected by the release policy", func(t *testing.T) {
+ body := expectStatus(t, registry.pushManifest("app", "1.1.0-rc1", manifest), http.StatusBadRequest)
+ if !strings.Contains(body, docker.ErrorTagInvalid) {
+ t.Fatalf("expected %s, got %s", docker.ErrorTagInvalid, body)
+ }
+ })
+
+ t.Run("a locked repository refuses to move an existing tag", func(t *testing.T) {
+ body := expectStatus(t, registry.pushManifest("app", "1.0.0", manifest), http.StatusConflict)
+ if !strings.Contains(body, docker.ErrorDenied) {
+ t.Fatalf("expected %s, got %s", docker.ErrorDenied, body)
+ }
+ })
+
+ t.Run("a variant tag is not a prerelease", func(t *testing.T) {
+ expectStatus(t, registry.pushManifest("app", "1.0.0-alpine", manifest), http.StatusCreated)
+ })
+}
+
+func TestDockerManifestMetadata(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ manifestDigest, layerDigest := registry.pushImage("team/api", "1.4.0")
+
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/images/docker/manifests?namespace=team&name=api&reference=1.4.0", ""), http.StatusOK)
+
+ var payload struct {
+ Digest string `json:"digest"`
+ MediaType string `json:"mediaType"`
+ TotalSize int64 `json:"totalSize"`
+ LayerCount int `json:"layerCount"`
+ IsIndex bool `json:"isIndex"`
+ OS string `json:"os"`
+ Architecture string `json:"architecture"`
+ ImageCreated int64 `json:"imageCreated"`
+ Layers []struct {
+ Digest string `json:"digest"`
+ Size int64 `json:"size"`
+ SharedWith int `json:"sharedWith"`
+ Stored bool `json:"stored"`
+ Foreign bool `json:"foreign"`
+ } `json:"layers"`
+ Config *struct {
+ Entrypoint []string `json:"entrypoint"`
+ History []struct {
+ CreatedBy string `json:"createdBy"`
+ EmptyLayer bool `json:"emptyLayer"`
+ } `json:"history"`
+ } `json:"config"`
+ PullBy map[string]string `json:"pullBy"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the manifest: %v", err)
+ }
+
+ t.Run("the summary describes the image", func(t *testing.T) {
+ if payload.Digest != manifestDigest {
+ t.Fatalf("digest = %q, want %q", payload.Digest, manifestDigest)
+ }
+ if payload.IsIndex {
+ t.Fatal("a single-platform image reported itself as an index")
+ }
+ if payload.OS != "linux" || payload.Architecture != "amd64" {
+ t.Fatalf("platform = %s/%s, want linux/amd64", payload.OS, payload.Architecture)
+ }
+ if payload.ImageCreated == 0 {
+ t.Fatal("the config timestamp was not read")
+ }
+ })
+
+ t.Run("the total size is the layers rather than the manifest", func(t *testing.T) {
+ // A manifest is a couple of hundred bytes; its config and layer are more.
+ if payload.TotalSize != int64(len(testImageConfig))+704 {
+ t.Fatalf("totalSize = %d, want %d", payload.TotalSize, len(testImageConfig)+704)
+ }
+ })
+
+ t.Run("the layer is listed as stored and unshared", func(t *testing.T) {
+ if payload.LayerCount != 1 || len(payload.Layers) != 1 {
+ t.Fatalf("layers = %v, want one", payload.Layers)
+ }
+ layer := payload.Layers[0]
+ if layer.Digest != layerDigest {
+ t.Fatalf("layer digest = %q, want %q", layer.Digest, layerDigest)
+ }
+ if !layer.Stored {
+ t.Fatal("the layer was pushed but is not reported as stored")
+ }
+ if layer.Foreign {
+ t.Fatal("an ordinary layer was reported as foreign")
+ }
+ if layer.SharedWith != 0 {
+ t.Fatalf("sharedWith = %d, want 0 for the only image using it", layer.SharedWith)
+ }
+ })
+
+ t.Run("the config is read from its blob", func(t *testing.T) {
+ if payload.Config == nil {
+ t.Fatal("the config was not read")
+ }
+ if len(payload.Config.Entrypoint) != 1 || payload.Config.Entrypoint[0] != "/bin/app" {
+ t.Fatalf("entrypoint = %v, want [/bin/app]", payload.Config.Entrypoint)
+ }
+ if len(payload.Config.History) != 0 {
+ t.Fatalf("history = %v, want none for a config without one", payload.Config.History)
+ }
+ })
+
+ t.Run("the pull references carry the repository prefix", func(t *testing.T) {
+ if payload.PullBy["tag"] != "images/team/api:1.4.0" {
+ t.Fatalf("pullBy.tag = %q", payload.PullBy["tag"])
+ }
+ if payload.PullBy["digest"] != "images/team/api@"+manifestDigest {
+ t.Fatalf("pullBy.digest = %q", payload.PullBy["digest"])
+ }
+ })
+
+ t.Run("a second image sharing the layer reports it as shared", func(t *testing.T) {
+ config := []byte(testImageConfig)
+ second := withAnnotation(
+ imageManifestFor(digestOf(config), len(config), map[string]int{layerDigest: 704}),
+ "org.opencontainers.image.revision", "second",
+ )
+ expectStatus(t, registry.pushManifest("team/api", "1.5.0", second), http.StatusCreated)
+
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/images/docker/manifests?namespace=team&name=api&reference=1.4.0", ""), http.StatusOK)
+
+ var reread struct {
+ Layers []struct {
+ SharedWith int `json:"sharedWith"`
+ } `json:"layers"`
+ }
+ if err := json.Unmarshal([]byte(body), &reread); err != nil {
+ t.Fatalf("decoding the manifest: %v", err)
+ }
+ if reread.Layers[0].SharedWith != 1 {
+ t.Fatalf("sharedWith = %d, want 1 now that a second image needs it", reread.Layers[0].SharedWith)
+ }
+ })
+}
+
+func TestDockerManifestMetadataForAnIndex(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ // Two single-platform images become the children of one index, which is what a
+ // buildx push of a multi-arch image produces.
+ config := []byte(testImageConfig)
+ configDigest := registry.pushBlob("multi/app", config)
+
+ children := []map[string]any{}
+ for index, platform := range []map[string]any{
+ {"os": "linux", "architecture": "amd64"},
+ {"os": "linux", "architecture": "arm64", "variant": "v8"},
+ } {
+ layer := bytes.Repeat([]byte{byte('a' + index)}, 128)
+ layerDigest := registry.pushBlob("multi/app", layer)
+
+ child := imageManifestFor(configDigest, len(config), map[string]int{layerDigest: len(layer)})
+ expectStatus(t, registry.pushManifest("multi/app", digestOf(child), child), http.StatusCreated)
+
+ children = append(children, map[string]any{
+ "mediaType": docker.MediaTypeOCIManifest,
+ "digest": digestOf(child),
+ "size": len(child),
+ "platform": platform,
+ })
+ }
+
+ index, err := json.Marshal(map[string]any{
+ "schemaVersion": 2,
+ "mediaType": docker.MediaTypeOCIIndex,
+ "manifests": children,
+ })
+ if err != nil {
+ t.Fatalf("building the index: %v", err)
+ }
+
+ response := registry.do(http.MethodPut, "/v2/images/multi/app/manifests/1.0", index, docker.MediaTypeOCIIndex)
+ expectStatus(t, response, http.StatusCreated)
+
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/images/docker/manifests?namespace=multi&name=app&reference=1.0", ""), http.StatusOK)
+
+ var payload struct {
+ IsIndex bool `json:"isIndex"`
+ TotalSize int64 `json:"totalSize"`
+ Children []struct {
+ Platform string `json:"platform"`
+ TotalSize int64 `json:"totalSize"`
+ LayerCount int `json:"layerCount"`
+ Indexed bool `json:"indexed"`
+ } `json:"children"`
+ Layers []any `json:"layers"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the index: %v", err)
+ }
+
+ if !payload.IsIndex {
+ t.Fatal("IsIndex = false, want true")
+ }
+ if len(payload.Layers) != 0 {
+ t.Fatalf("an index reported %d layers of its own, want none", len(payload.Layers))
+ }
+ if len(payload.Children) != 2 {
+ t.Fatalf("children = %v, want two platforms", payload.Children)
+ }
+
+ platforms := []string{"linux/amd64", "linux/arm64/v8"}
+ for position, child := range payload.Children {
+ if child.Platform != platforms[position] {
+ t.Fatalf("child %d platform = %q, want %q", position, child.Platform, platforms[position])
+ }
+ // The children were pushed first, so the index knows their real weight
+ // rather than only the size of their manifest documents.
+ if !child.Indexed || child.LayerCount != 1 {
+ t.Fatalf("child %d was not indexed: %+v", position, child)
+ }
+ }
+
+ var childTotal int64
+ for _, child := range payload.Children {
+ childTotal += child.TotalSize
+ }
+ if payload.TotalSize != childTotal {
+ t.Fatalf("totalSize = %d, want the sum of its children %d", payload.TotalSize, childTotal)
+ }
+}
+
+// Every buildkit multi-platform build attaches an attestation child, which declares
+// a platform of unknown/unknown. Without the descriptor annotation it would read as a
+// broken architecture rather than as provenance.
+func TestDockerIndexAttestationChild(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+
+ config := []byte(testImageConfig)
+ configDigest := registry.pushBlob("multi/app", config)
+
+ platform := imageManifestFor(configDigest, len(config), nil)
+ expectStatus(t, registry.pushManifest("multi/app", digestOf(platform), platform), http.StatusCreated)
+
+ attestation := withAnnotation(
+ imageManifestFor(configDigest, len(config), nil),
+ "vnd.docker.reference.type", "attestation-manifest",
+ )
+ expectStatus(t, registry.pushManifest("multi/app", digestOf(attestation), attestation), http.StatusCreated)
+
+ index, err := json.Marshal(map[string]any{
+ "schemaVersion": 2,
+ "mediaType": docker.MediaTypeOCIIndex,
+ "manifests": []map[string]any{
+ {
+ "mediaType": docker.MediaTypeOCIManifest,
+ "digest": digestOf(platform),
+ "size": len(platform),
+ "platform": map[string]any{"os": "linux", "architecture": "amd64"},
+ },
+ {
+ "mediaType": docker.MediaTypeOCIManifest,
+ "digest": digestOf(attestation),
+ "size": len(attestation),
+ // buildkit really does declare unknown/unknown here.
+ "platform": map[string]any{"os": "unknown", "architecture": "unknown"},
+ "annotations": map[string]string{"vnd.docker.reference.type": "attestation-manifest"},
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("building the index: %v", err)
+ }
+
+ expectStatus(t, registry.do(http.MethodPut, "/v2/images/multi/app/manifests/1.0", index, docker.MediaTypeOCIIndex), http.StatusCreated)
+
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/images/docker/manifests?namespace=multi&name=app&reference=1.0", ""), http.StatusOK)
+
+ var payload struct {
+ ImageCreated int64 `json:"imageCreated"`
+ Children []struct {
+ Platform string `json:"platform"`
+ ReferenceType string `json:"referenceType"`
+ } `json:"children"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the index: %v", err)
+ }
+ if len(payload.Children) != 2 {
+ t.Fatalf("children = %v, want two", payload.Children)
+ }
+
+ if payload.Children[0].ReferenceType != "" {
+ t.Fatalf("the real platform carries a reference type: %q", payload.Children[0].ReferenceType)
+ }
+ if payload.Children[1].ReferenceType != "attestation-manifest" {
+ t.Fatalf("the attestation reference type = %q, want attestation-manifest", payload.Children[1].ReferenceType)
+ }
+
+ // An index has no config of its own, so its build time comes from its children
+ // rather than being reported as unknown.
+ if payload.ImageCreated == 0 {
+ t.Fatal("the index reported no build time, want the newest child's")
+ }
+}
+
+func TestDockerManifestMetadataRejections(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+ registry := instance.registry("images")
+ registry.pushImage("app", "1.0")
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"jars","format":"maven2"}`), http.StatusCreated)
+
+ cases := []struct {
+ name string
+ path string
+ status int
+ }{
+ {"an unknown tag", "/api/repositories/images/docker/manifests?name=app&reference=9.9", http.StatusNotFound},
+ {"an unknown image", "/api/repositories/images/docker/manifests?name=absent&reference=1.0", http.StatusNotFound},
+ {"a missing reference", "/api/repositories/images/docker/manifests?name=app", http.StatusBadRequest},
+ {"a missing name", "/api/repositories/images/docker/manifests?reference=1.0", http.StatusBadRequest},
+ {"a bad reference", "/api/repositories/images/docker/manifests?name=app&reference=.hidden", http.StatusBadRequest},
+ {"a repository of another format", "/api/repositories/jars/docker/manifests?name=app&reference=1.0", http.StatusBadRequest},
+ {"an unknown repository", "/api/repositories/nowhere/docker/manifests?name=app&reference=1.0", http.StatusNotFound},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodGet, testCase.path, ""), testCase.status)
+ })
+ }
+}
diff --git a/internal/server/formats.go b/internal/server/formats.go
index 18a312a..8579e3c 100644
--- a/internal/server/formats.go
+++ b/internal/server/formats.go
@@ -1,6 +1,7 @@
package server
import (
+ "arca/internal/docker"
"arca/internal/format"
"arca/internal/maven"
"arca/internal/npm"
@@ -20,6 +21,8 @@ func layoutForFormat(name string) format.Layout {
return npm.Layout{}
case format.P2:
return p2.Layout{}
+ case format.Docker:
+ return docker.Layout{}
default:
return maven.Layout{}
}
diff --git a/internal/server/http.go b/internal/server/http.go
index a00f31b..00f50bf 100644
--- a/internal/server/http.go
+++ b/internal/server/http.go
@@ -50,6 +50,7 @@ func (s *Server) routes() {
api.Handle("/repositories/{name}/artifacts", handler(s.routeDeleteArtifact)).Methods(http.MethodDelete)
api.Handle("/repositories/{name}/artifacts/files", handler(s.routeArtifactFiles)).Methods(http.MethodGet)
api.Handle("/repositories/{name}/recent", handler(s.routeRecent)).Methods(http.MethodGet)
+ api.Handle("/repositories/{name}/docker/manifests", handler(s.routeDockerManifest)).Methods(http.MethodGet)
api.Handle("/search", handler(s.routeSearch)).Methods(http.MethodGet)
api.Handle("/admin/health", handler(s.routeHealth)).Methods(http.MethodGet)
@@ -66,6 +67,10 @@ func (s *Server) routes() {
api.Handle("/admin/migration", handler(s.routeCancelMigration)).Methods(http.MethodDelete)
api.Handle("/admin/migration/preview", handler(s.routeMigrationPreview)).Methods(http.MethodPost)
+ api.Handle("/admin/docker/sweep", handler(s.routeDockerSweepPreview)).Methods(http.MethodGet)
+ api.Handle("/admin/docker/sweep", handler(s.routeDockerSweep)).Methods(http.MethodPost)
+ api.Handle("/admin/docker/reindex", handler(s.routeDockerReindex)).Methods(http.MethodPost)
+
api.PathPrefix("/").HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "Not found"})
})
@@ -74,6 +79,16 @@ func (s *Server) routes() {
s.router.Handle("/repository/{repository}", http.HandlerFunc(s.handleRepository)).Methods(repositoryMethods...)
s.router.PathPrefix("/repository/{repository}/").HandlerFunc(s.handleRepository).Methods(repositoryMethods...)
+ // The registry API lives at the host root because a Docker client builds its
+ // URLs from the image reference and cannot be pointed at a path prefix. PATCH
+ // appears here and nowhere else: it is how a chunked blob push appends.
+ registryMethods := []string{
+ http.MethodGet, http.MethodHead, http.MethodPost,
+ http.MethodPut, http.MethodPatch, http.MethodDelete,
+ }
+ s.router.Handle("/v2", http.HandlerFunc(s.handleDockerRegistry)).Methods(registryMethods...)
+ s.router.PathPrefix("/v2/").HandlerFunc(s.handleDockerRegistry).Methods(registryMethods...)
+
s.mountFrontend()
}
diff --git a/internal/server/maintenance_test.go b/internal/server/maintenance_test.go
index c0fb39c..c3c4514 100644
--- a/internal/server/maintenance_test.go
+++ b/internal/server/maintenance_test.go
@@ -68,7 +68,7 @@ func TestMigrationPreviewWritesNothing(t *testing.T) {
`"format":"p2"`,
`"action":"create"`,
`"action":"skip"`,
- "arca has no docker format",
+ "arca has no rubygems format",
} {
if !strings.Contains(body, expected) {
t.Errorf("the preview is missing %s: %s", expected, body)
@@ -125,8 +125,8 @@ func TestMigrationRunsInTheBackground(t *testing.T) {
if entry := byName["maven-public"]; !strings.Contains(entry.Reason, "group") {
t.Fatalf("maven-public = %+v", entry)
}
- if entry := byName["docker-hosted"]; entry.State != repositorySkipped {
- t.Fatalf("docker-hosted = %+v", entry)
+ if entry := byName["gems"]; entry.State != repositorySkipped {
+ t.Fatalf("gems = %+v", entry)
}
})
diff --git a/internal/server/migrate_test.go b/internal/server/migrate_test.go
index 71686a4..05c23be 100644
--- a/internal/server/migrate_test.go
+++ b/internal/server/migrate_test.go
@@ -23,13 +23,34 @@ type fakeNexus struct {
mutex sync.Mutex
methods map[string]int
assets map[string]map[string]string
- repos string
+ // components maps a repository to the versions it holds and the asset paths of
+ // each. A real Nexus lists a docker tag here and nowhere else.
+ components map[string][]fakeComponent
+ // componentOnly are paths the asset endpoint withholds while still serving their
+ // content. That is exactly how a real Nexus behaves for a docker tag, and it is the
+ // whole reason a migration has to walk components as well as assets.
+ componentOnly map[string]bool
+ repos string
+}
+
+// fakeComponent mirrors what /service/rest/v1/components returns: a name, a version and
+// the assets nested under it.
+type fakeComponent struct {
+ name string
+ version string
+ paths []string
}
func newFakeNexus(t *testing.T, repos string, assets map[string]map[string]string) *fakeNexus {
t.Helper()
- remote := &fakeNexus{methods: map[string]int{}, assets: assets, repos: repos}
+ remote := &fakeNexus{
+ methods: map[string]int{},
+ assets: assets,
+ components: map[string][]fakeComponent{},
+ componentOnly: map[string]bool{},
+ repos: repos,
+ }
remote.server = httptest.NewServer(http.HandlerFunc(remote.serve))
t.Cleanup(remote.server.Close)
@@ -48,6 +69,9 @@ func (n *fakeNexus) serve(writer http.ResponseWriter, request *http.Request) {
case request.URL.Path == "/service/rest/v1/assets":
n.serveAssets(writer, request.URL.Query().Get("repository"))
+ case request.URL.Path == "/service/rest/v1/components":
+ n.serveComponents(writer, request.URL.Query().Get("repository"))
+
case strings.HasPrefix(request.URL.Path, "/repository/"):
n.serveContent(writer, strings.TrimPrefix(request.URL.Path, "/repository/"))
@@ -59,6 +83,9 @@ func (n *fakeNexus) serve(writer http.ResponseWriter, request *http.Request) {
func (n *fakeNexus) serveAssets(writer http.ResponseWriter, repository string) {
items := []map[string]any{}
for path, content := range n.assets[repository] {
+ if n.componentOnly[repository+"/"+path] {
+ continue
+ }
items = append(items, map[string]any{
"path": path,
"downloadUrl": n.server.URL + "/repository/" + repository + "/" + path,
@@ -68,6 +95,26 @@ func (n *fakeNexus) serveAssets(writer http.ResponseWriter, repository string) {
json.NewEncoder(writer).Encode(map[string]any{"items": items, "continuationToken": ""})
}
+func (n *fakeNexus) serveComponents(writer http.ResponseWriter, repository string) {
+ items := []map[string]any{}
+ for _, component := range n.components[repository] {
+ assets := []map[string]any{}
+ for _, path := range component.paths {
+ assets = append(assets, map[string]any{
+ "path": path,
+ "downloadUrl": n.server.URL + "/repository/" + repository + "/" + path,
+ "fileSize": len(n.assets[repository][path]),
+ })
+ }
+ items = append(items, map[string]any{
+ "name": component.name,
+ "version": component.version,
+ "assets": assets,
+ })
+ }
+ json.NewEncoder(writer).Encode(map[string]any{"items": items, "continuationToken": ""})
+}
+
func (n *fakeNexus) serveContent(writer http.ResponseWriter, target string) {
repository, path, _ := strings.Cut(target, "/")
content, ok := n.assets[repository][path]
@@ -99,15 +146,61 @@ const fakeRepositories = `[
{"name":"maven-snapshots","format":"maven2","type":"hosted","attributes":{"maven":{"versionPolicy":"SNAPSHOT"}}},
{"name":"maven-central","format":"maven2","type":"proxy","attributes":{"proxy":{"remoteUrl":"https://repo1.maven.org/maven2/"}}},
{"name":"maven-public","format":"maven2","type":"group","attributes":{"group":{"memberNames":["maven-central","maven-snapshots"]}}},
- {"name":"docker-hosted","format":"docker","type":"hosted"}
+ {"name":"docker-hosted","format":"docker","type":"hosted"},
+ {"name":"gems","format":"rubygems","type":"hosted"}
]`
+// dockerSource is a Nexus docker repository as one really looks: blobs on the shared
+// path, manifests addressed by digest in the asset listing, and the tag reachable only
+// through the component listing. The digests are real so the reindex can read the config
+// blob the manifest points at.
+type dockerSource struct {
+ assets map[string]string
+ components []fakeComponent
+ manifest string
+ config string
+ layer string
+}
+
+func newDockerSource() dockerSource {
+ config := testImageConfig
+ layer := "a-compressed-layer"
+
+ manifest := string(imageManifestFor(
+ digestOf([]byte(config)), len(config),
+ map[string]int{digestOf([]byte(layer)): len(layer)},
+ ))
+
+ source := dockerSource{
+ manifest: manifest,
+ config: config,
+ layer: layer,
+ components: []fakeComponent{
+ {name: "team/api", version: "1.0", paths: []string{"v2/team/api/manifests/1.0"}},
+ },
+ }
+ source.assets = map[string]string{
+ "v2/-/blobs/" + digestOf([]byte(config)): config,
+ "v2/-/blobs/" + digestOf([]byte(layer)): layer,
+ "v2/team/api/manifests/" + digestOf([]byte(manifest)): manifest,
+ // Downloadable, but withheld from the asset listing below, which is what a real
+ // Nexus does with a docker tag.
+ "v2/team/api/manifests/1.0": manifest,
+ // Nexus exposes paths that mean nothing to this server. They have to be counted
+ // rather than dropped in silence, or a copy claims to be complete when it is not.
+ "v2/team/api/tags/list": "{}",
+ }
+ return source
+}
+
func migrationFixture(t *testing.T) (*testInstance, *fakeNexus, *migrate.Runner, []migrate.Decision) {
t.Helper()
instance := newTestInstance(t)
instance.setup()
+ images := newDockerSource()
+
remote := newFakeNexus(t, fakeRepositories, map[string]map[string]string{
"p2-releases": {
"logging/2.0/artifacts.jar": "artifacts-bytes",
@@ -120,7 +213,10 @@ func migrationFixture(t *testing.T) (*testInstance, *fakeNexus, *migrate.Runner,
"maven-snapshots": {
"com/example/seeded/1.0.0-SNAPSHOT/seeded-1.0.0-SNAPSHOT.jar": "seeded-bytes",
},
+ "docker-hosted": images.assets,
})
+ remote.components["docker-hosted"] = images.components
+ remote.componentOnly["docker-hosted/v2/team/api/manifests/1.0"] = true
source, err := nexus.New(remote.server.URL, "reader", "secret", 30*time.Second)
if err != nil {
@@ -223,7 +319,7 @@ func TestMigrationReproducesNexusRepositories(t *testing.T) {
})
t.Run("unsupported repositories are skipped, not invented", func(t *testing.T) {
- for _, name := range []string{"maven-public", "docker-hosted"} {
+ for _, name := range []string{"maven-public", "gems"} {
if byName[name].Created {
t.Fatalf("%s should not have been created", name)
}
@@ -277,3 +373,146 @@ func TestMigrationIsResumable(t *testing.T) {
}
}
}
+
+// A docker migration is the case that cannot work off the asset listing alone: Nexus
+// reports manifests there by digest only, so a copy that ignored the component listing
+// would transfer every byte and leave nothing pullable.
+func TestMigrationReproducesDockerImages(t *testing.T) {
+ instance, remote, runner, plan := migrationFixture(t)
+ images := newDockerSource()
+
+ results := runner.Apply(context.Background(), plan)
+
+ var docker migrate.Result
+ for _, result := range results {
+ if result.Decision.Name() == "docker-hosted" {
+ docker = result
+ }
+ }
+
+ t.Run("the repository was created with a mixed policy", func(t *testing.T) {
+ if !docker.Created {
+ t.Fatalf("docker-hosted was not created: %+v", docker)
+ }
+ body := expectStatus(t, instance.api(http.MethodGet, "/api/repositories/docker-hosted", ""), http.StatusOK)
+ if !strings.Contains(body, `"policy":"mixed"`) {
+ t.Fatalf("policy is not mixed: %s", body)
+ }
+ })
+
+ t.Run("the paths this server has no place for are counted, not dropped in silence", func(t *testing.T) {
+ if docker.Untranslatable != 1 {
+ t.Fatalf("untranslatable = %d, want the one tags/list path", docker.Untranslatable)
+ }
+ if docker.Failed != 0 {
+ t.Fatalf("failed = %d: %v", docker.Failed, docker.Errors)
+ }
+ })
+
+ registry := ®istryClient{instance: instance, repository: "docker-hosted"}
+
+ t.Run("the tag is pullable, which only the component walk makes possible", func(t *testing.T) {
+ response := registry.do(http.MethodGet, "/v2/docker-hosted/team/api/manifests/1.0", nil, "")
+ body := expectStatus(t, response, http.StatusOK)
+
+ if body != images.manifest {
+ t.Fatalf("the migrated manifest differs from the source")
+ }
+ if got := response.Header.Get("Docker-Content-Digest"); got != digestOf([]byte(images.manifest)) {
+ t.Fatalf("digest = %q, want %q", got, digestOf([]byte(images.manifest)))
+ }
+ })
+
+ t.Run("the manifest is also pullable by digest", func(t *testing.T) {
+ expectStatus(t, registry.do(http.MethodGet,
+ "/v2/docker-hosted/team/api/manifests/"+digestOf([]byte(images.manifest)), nil, ""), http.StatusOK)
+ })
+
+ t.Run("the shared blob path was translated to this layout", func(t *testing.T) {
+ for _, content := range []string{images.config, images.layer} {
+ body := expectStatus(t, registry.do(http.MethodGet,
+ "/v2/docker-hosted/team/api/blobs/"+digestOf([]byte(content)), nil, ""), http.StatusOK)
+ if body != content {
+ t.Fatalf("a migrated blob differs from the source")
+ }
+ }
+ })
+
+ t.Run("the tag became a version the UI can list", func(t *testing.T) {
+ body := expectStatus(t, registry.do(http.MethodGet, "/v2/docker-hosted/team/api/tags/list", nil, ""), http.StatusOK)
+ if !strings.Contains(body, `"1.0"`) {
+ t.Fatalf("tags = %s", body)
+ }
+ })
+
+ t.Run("settling indexed the manifest, platform and all", func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodGet,
+ "/api/repositories/docker-hosted/docker/manifests?namespace=team&name=api&reference=1.0", ""), http.StatusOK)
+
+ var payload struct {
+ Architecture string `json:"architecture"`
+ OS string `json:"os"`
+ LayerCount int `json:"layerCount"`
+ Layers []struct {
+ Stored bool `json:"stored"`
+ } `json:"layers"`
+ Config *struct {
+ Entrypoint []string `json:"entrypoint"`
+ } `json:"config"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding the manifest: %v", err)
+ }
+
+ // The platform comes from the config blob, which is what makes this prove the
+ // copy finished before the metadata was built.
+ if payload.OS != "linux" || payload.Architecture != "amd64" {
+ t.Fatalf("platform = %s/%s, want linux/amd64", payload.OS, payload.Architecture)
+ }
+ if payload.LayerCount != 1 || len(payload.Layers) != 1 || !payload.Layers[0].Stored {
+ t.Fatalf("layers = %+v", payload.Layers)
+ }
+ if payload.Config == nil || len(payload.Config.Entrypoint) != 1 {
+ t.Fatalf("config = %+v", payload.Config)
+ }
+ })
+
+ t.Run("the migration only ever read from Nexus", func(t *testing.T) {
+ if writes := remote.writeRequests(); writes != 0 {
+ t.Fatalf("the migration made %d write requests to Nexus", writes)
+ }
+ })
+}
+
+// Rerunning must not re-transfer anything, which for docker means the translated path
+// is what gets checked rather than the source path.
+func TestMigrationOfDockerIsResumable(t *testing.T) {
+ _, _, runner, plan := migrationFixture(t)
+
+ first := runner.Apply(context.Background(), plan)
+ second := runner.Apply(context.Background(), plan)
+
+ find := func(results []migrate.Result) migrate.Result {
+ for _, result := range results {
+ if result.Decision.Name() == "docker-hosted" {
+ return result
+ }
+ }
+ return migrate.Result{}
+ }
+
+ if find(first).Copied == 0 {
+ t.Fatalf("the first run copied nothing: %+v", find(first))
+ }
+ if copied := find(second).Copied; copied != 0 {
+ t.Fatalf("the second run copied %d assets again", copied)
+ }
+ if find(second).Skipped != find(first).Copied {
+ t.Fatalf("the second run skipped %d, want the %d the first copied",
+ find(second).Skipped, find(first).Copied)
+ }
+ if find(second).Untranslatable != find(first).Untranslatable {
+ t.Fatalf("the untranslatable count changed between runs: %d then %d",
+ find(first).Untranslatable, find(second).Untranslatable)
+ }
+}
diff --git a/internal/server/migration.go b/internal/server/migration.go
index b0b2d92..55a9877 100644
--- a/internal/server/migration.go
+++ b/internal/server/migration.go
@@ -30,18 +30,19 @@ const (
)
type migrationRepository struct {
- Name string `json:"name"`
- Source string `json:"source"`
- Action string `json:"action"`
- Format string `json:"format"`
- Type string `json:"type"`
- Reason string `json:"reason"`
- State string `json:"state"`
- Existed bool `json:"existed"`
- Copied int `json:"copied"`
- Present int `json:"present"`
- Failed int `json:"failed"`
- CopyingAssets bool `json:"copyingAssets"`
+ Name string `json:"name"`
+ Source string `json:"source"`
+ Action string `json:"action"`
+ Format string `json:"format"`
+ Type string `json:"type"`
+ Reason string `json:"reason"`
+ State string `json:"state"`
+ Existed bool `json:"existed"`
+ Copied int `json:"copied"`
+ Present int `json:"present"`
+ Failed int `json:"failed"`
+ Untranslatable int `json:"untranslatable"`
+ CopyingAssets bool `json:"copyingAssets"`
}
type migrationStatus struct {
@@ -195,6 +196,7 @@ func (m *migrationRun) record(results []migrate.Result) {
m.update(result.Decision.Name(), func(entry *migrationRepository) {
entry.Existed = result.Existed
entry.Copied, entry.Present, entry.Failed = result.Copied, result.Skipped, result.Failed
+ entry.Untranslatable = result.Untranslatable
entry.State = repositoryDone
if result.Failed > 0 {
entry.State = repositoryFailed
@@ -278,4 +280,27 @@ func (d *localDestination) PutAsset(_ context.Context, repository, path string,
return err
}
+// Settled builds the metadata a format cannot index as its files arrive. Only docker
+// needs it, and only after every blob is present: a manifest's platform and labels come
+// from a config blob the copy order gives no guarantee of having seen yet.
+func (d *localDestination) Settled(_ context.Context, decision migrate.Decision) error {
+ if decision.Format != format.Docker {
+ return nil
+ }
+
+ stored, err := d.repository(decision.Name())
+ if err != nil {
+ return err
+ }
+
+ result, err := d.server.reindexDockerRepository(stored)
+ if err != nil {
+ return err
+ }
+ if result.Failed > 0 {
+ return fmt.Errorf("%d of %d manifests could not be indexed", result.Failed, result.Failed+result.Manifests)
+ }
+ return nil
+}
+
func sanitizeSource(url string) string { return strings.TrimSpace(url) }
diff --git a/internal/server/proxy.go b/internal/server/proxy.go
index b27eca7..8146ff5 100644
--- a/internal/server/proxy.go
+++ b/internal/server/proxy.go
@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/log"
"arca/internal/blob"
+ "arca/internal/docker"
"arca/internal/format"
"arca/internal/maven"
"arca/internal/proxy"
@@ -53,6 +54,15 @@ type proxyFetch struct {
// packument rewrite this one does not depend on the request, so it runs once
// on the way in and the stored digests describe what is actually served.
Rewrite func(document []byte) ([]byte, error)
+ // Indexed runs after a fetched asset is stored, for a format that keeps parsed
+ // metadata beside the bytes and wants a cache fill to build it too.
+ Indexed func(asset *models.Asset) error
+ // Headers adds response headers derived from the cached row. Docker needs the
+ // manifest digest, which is the asset's own SHA256 and so is not known until the
+ // row is resolved, whether that was from the cache or from the remote.
+ Headers func(asset *models.Asset) map[string]string
+ // Limit overrides the upload ceiling, as a container layer needs.
+ Limit int64
}
func pathFetch(path string) proxyFetch {
@@ -79,7 +89,7 @@ func (s *Server) serveProxyFetch(writer http.ResponseWriter, request *http.Reque
return
}
- if s.serveCachedAsset(writer, request, repository, asset) {
+ if s.serveCachedAsset(writer, request, repository, fetch, asset) {
return
}
}
@@ -126,9 +136,15 @@ func (s *Server) proxyAsset(repository *models.Repository, fetch proxyFetch) (*m
// file has gone missing is dropped rather than left to answer every future
// request with a 404: eviction removes rows before files, so a fetch that lands
// in that window can outlive its own blob.
-func (s *Server) serveCachedAsset(writer http.ResponseWriter, request *http.Request, repository *models.Repository, asset *models.Asset) bool {
+func (s *Server) serveCachedAsset(writer http.ResponseWriter, request *http.Request, repository *models.Repository, fetch proxyFetch, asset *models.Asset) bool {
s.recordCacheHit(request, repository, asset)
+ if fetch.Headers != nil {
+ for name, value := range fetch.Headers(asset) {
+ writer.Header().Set(name, value)
+ }
+ }
+
if s.writeAsset(writer, request, asset) {
return true
}
@@ -162,6 +178,11 @@ func (s *Server) dropDanglingAsset(asset *models.Asset) {
// artifact is already cached is answered from that copy, which keeps clients
// that insist on one working against upstreams that publish only some of them.
func (s *Server) serveProxyMiss(writer http.ResponseWriter, repository *models.Repository, path string) {
+ if repository.Format == format.Docker {
+ dockerError(writer, http.StatusNotFound, docker.ErrorManifestUnknown, "The remote does not have this")
+ return
+ }
+
base, checksum := maven.SplitChecksum(path)
if checksum != "" {
if parent, err := s.store.FindAsset(repository.ID, base); err == nil {
@@ -244,7 +265,8 @@ func (s *Server) storeRemoteAsset(repository *models.Repository, fetch proxyFetc
body = rewritten
}
- _, err := s.storeUpload(repository, fetch.CachePath, body, uploadDetails{
+ asset, err := s.storeUpload(repository, fetch.CachePath, body, uploadDetails{
+ Limit: fetch.Limit,
FetchedAt: now,
ExpiresAt: cacheExpiry(layoutFor(repository), repository, fetch.CachePath),
LastAccessedAt: now,
@@ -258,6 +280,14 @@ func (s *Server) storeRemoteAsset(repository *models.Repository, fetch proxyFetc
return err
}
+ // Indexing failure is logged rather than failing the fetch: the bytes are cached
+ // and servable, and a missing index only costs the UI its detail view.
+ if fetch.Indexed != nil {
+ if err := fetch.Indexed(asset); err != nil {
+ log.Errorf("indexing the cached %s/%s failed: %v", repository.Name, fetch.CachePath, err)
+ }
+ }
+
return s.store.ClearRemoteMiss(repository.ID, fetch.CachePath)
}
diff --git a/internal/server/repository.go b/internal/server/repository.go
index 2fcbb75..2603067 100644
--- a/internal/server/repository.go
+++ b/internal/server/repository.go
@@ -44,6 +44,8 @@ func (s *Server) handleRepository(writer http.ResponseWriter, request *http.Requ
s.serveNPM(writer, request, repository, path)
case format.P2:
s.serveP2(writer, request, repository, path)
+ case format.Docker:
+ s.serveDockerBrowse(writer, request, repository, path)
default:
s.serveMaven(writer, request, repository, path)
}
@@ -89,7 +91,15 @@ func (s *Server) writeAsset(writer http.ResponseWriter, request *http.Request, a
}
type uploadDetails struct {
- UploadedBy *string
+ UploadedBy *string
+ // ContentType wins over what the layout derives from the filename. A docker
+ // manifest is the case that needs it: the same filename holds an OCI
+ // manifest, a Docker one or an index, and only the push says which.
+ ContentType string
+ // Limit overrides the configured upload ceiling: zero takes MaxUploadBytes and
+ // a negative value lifts the limit entirely, which is what a container layer
+ // needs since it is routinely larger than any sane ceiling for an artifact.
+ Limit int64
FetchedAt int64
ExpiresAt int64
LastAccessedAt int64
@@ -103,7 +113,15 @@ func (s *Server) storeUpload(repository *models.Repository, path string, body io
layout := layoutFor(repository)
key := repository.ID + "/" + path
- size, digests, err := s.blobs.Put(key, body, s.config.MaxUploadBytes)
+ limit := s.config.MaxUploadBytes
+ switch {
+ case details.Limit < 0:
+ limit = 0
+ case details.Limit > 0:
+ limit = details.Limit
+ }
+
+ size, digests, err := s.blobs.Put(key, body, limit)
if err != nil {
return nil, err
}
@@ -127,13 +145,18 @@ func (s *Server) storeUpload(repository *models.Repository, path string, body io
segments := strings.Split(path, "/")
+ contentType := details.ContentType
+ if contentType == "" {
+ contentType = layout.ContentType(segments[len(segments)-1])
+ }
+
asset := &models.Asset{
RepositoryID: repository.ID,
ComponentID: componentID,
Path: path,
StorageKey: key,
Size: size,
- ContentType: layout.ContentType(segments[len(segments)-1]),
+ ContentType: contentType,
MD5: digests.MD5,
SHA1: digests.SHA1,
SHA256: digests.SHA256,
diff --git a/internal/server/server.go b/internal/server/server.go
index 36a64c5..d5f6700 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -133,6 +133,8 @@ func (s *Server) runMaintenance() {
log.Infof("removed %d expired upstream misses", removed)
}
+ s.purgeStaleDockerUploads(time.Now())
+ s.purgeUnreachableDockerContent(time.Now())
s.purgeIdleCaches(time.Now())
}
}
diff --git a/internal/store/assets.go b/internal/store/assets.go
index d7fcd8e..3758d8b 100644
--- a/internal/store/assets.go
+++ b/internal/store/assets.go
@@ -47,6 +47,14 @@ func (s *Store) MarkAssetRefreshed(id string, expiresAt int64, etag, lastModifie
}).Error
}
+// SetAssetContentType corrects what a cache fill recorded. A docker manifest's media
+// type is content rather than presentation, since a client negotiates on it, and the
+// filename it is stored under cannot tell an image manifest from an index.
+func (s *Store) SetAssetContentType(id, contentType string) error {
+ return s.db.Model(&models.Asset{}).Where("id = ?", id).
+ UpdateColumn("content_type", contentType).Error
+}
+
func (s *Store) MarkAssetAccessed(id string) error {
return s.db.Model(&models.Asset{}).Where("id = ?", id).
UpdateColumn("last_accessed_at", NowMillis()).Error
@@ -69,6 +77,12 @@ func underPrefix(prefix string) assetSelection {
}
}
+func atPaths(paths []string) assetSelection {
+ return func(query *gorm.DB) *gorm.DB {
+ return query.Where("path IN ?", paths)
+ }
+}
+
func ofComponents(componentIDs []string) assetSelection {
return func(query *gorm.DB) *gorm.DB {
return query.Where("component_id IN ?", componentIDs)
@@ -95,6 +109,15 @@ func (s *Store) DeleteAssetTree(repositoryID, prefix string) ([]string, error) {
return s.deleteAssets(repositoryID, underPrefix(prefix))
}
+// DeleteAssetsAt removes a named set of paths, which is what a sweep produces: the
+// files it decided are unreachable rather than everything under one prefix.
+func (s *Store) DeleteAssetsAt(repositoryID string, paths []string) ([]string, error) {
+ if len(paths) == 0 {
+ return nil, nil
+ }
+ return s.deleteAssets(repositoryID, atPaths(paths))
+}
+
func (s *Store) deleteAssets(repositoryID string, selection assetSelection) ([]string, error) {
var keys []string
diff --git a/internal/store/docker.go b/internal/store/docker.go
new file mode 100644
index 0000000..0b3075f
--- /dev/null
+++ b/internal/store/docker.go
@@ -0,0 +1,318 @@
+package store
+
+import (
+ "gorm.io/gorm/clause"
+
+ "arca/internal/store/models"
+)
+
+func (s *Store) CreateDockerUpload(upload *models.DockerUpload) error {
+ now := NowMillis()
+ upload.CreatedAt = now
+ upload.UpdatedAt = now
+ return s.db.Create(upload).Error
+}
+
+func (s *Store) DockerUpload(repositoryID, id string) (*models.DockerUpload, error) {
+ var upload models.DockerUpload
+ err := s.db.First(&upload, "id = ? AND repository_id = ?", id, repositoryID).Error
+ if err != nil {
+ return nil, err
+ }
+ return &upload, nil
+}
+
+func (s *Store) SetDockerUploadSize(id string, size int64) error {
+ return s.db.Model(&models.DockerUpload{}).Where("id = ?", id).
+ UpdateColumns(map[string]any{"size": size, "updated_at": NowMillis()}).Error
+}
+
+func (s *Store) DeleteDockerUpload(id string) error {
+ return s.db.Delete(&models.DockerUpload{}, "id = ?", id).Error
+}
+
+// StaleDockerUploads names the sessions a client walked away from, so the sweep
+// can drop their files before removing the rows.
+func (s *Store) StaleDockerUploads(cutoff int64) ([]string, error) {
+ var ids []string
+ err := s.db.Model(&models.DockerUpload{}).
+ Where("updated_at < ?", cutoff).
+ Pluck("id", &ids).Error
+ return ids, err
+}
+
+func (s *Store) DeleteDockerUploads(ids []string) error {
+ if len(ids) == 0 {
+ return nil
+ }
+ return s.db.Delete(&models.DockerUpload{}, "id IN ?", ids).Error
+}
+
+// SaveDockerManifest records a parsed manifest together with everything it
+// references. The references are replaced rather than merged so a re-pushed
+// digest cannot accumulate edges from an earlier parse.
+func (s *Store) SaveDockerManifest(manifest *models.DockerManifest, references []models.DockerReference) error {
+ now := NowMillis()
+
+ return s.Transaction(func(tx *Store) error {
+ manifest.ID = NewID()
+ manifest.CreatedAt = now
+ manifest.UpdatedAt = now
+
+ err := tx.db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "repository_id"}, {Name: "digest"}},
+ DoUpdates: clause.AssignmentColumns([]string{
+ "media_type", "size", "namespace", "name", "config_digest",
+ "architecture", "os", "variant", "image_created", "layer_count",
+ "total_size", "labels", "annotations", "subject", "updated_at",
+ }),
+ }).Create(manifest).Error
+ if err != nil {
+ return err
+ }
+
+ err = tx.query().Where("repository_id = ? AND manifest_digest = ?", manifest.RepositoryID, manifest.Digest).
+ Delete(&models.DockerReference{}).Error
+ if err != nil {
+ return err
+ }
+ if len(references) == 0 {
+ return nil
+ }
+ return tx.db.Create(&references).Error
+ })
+}
+
+func (s *Store) DockerManifest(repositoryID, digest string) (*models.DockerManifest, error) {
+ var manifest models.DockerManifest
+ err := s.db.First(&manifest, "repository_id = ? AND digest = ?", repositoryID, digest).Error
+ if err != nil {
+ return nil, err
+ }
+ return &manifest, nil
+}
+
+// UpdateDockerManifestPlatform writes back the fields that could only be read once the
+// config blob was local, which on a proxy is always after the manifest itself.
+func (s *Store) UpdateDockerManifestPlatform(manifest *models.DockerManifest) error {
+ return s.db.Model(&models.DockerManifest{}).
+ Where("repository_id = ? AND digest = ?", manifest.RepositoryID, manifest.Digest).
+ UpdateColumns(map[string]any{
+ "architecture": manifest.Architecture,
+ "os": manifest.OS,
+ "variant": manifest.Variant,
+ "image_created": manifest.ImageCreated,
+ "labels": manifest.Labels,
+ }).Error
+}
+
+func (s *Store) DockerReferences(repositoryID, digest string) ([]models.DockerReference, error) {
+ var references []models.DockerReference
+ err := s.db.
+ Where("repository_id = ? AND manifest_digest = ?", repositoryID, digest).
+ Order("kind, position").
+ Find(&references).Error
+ return references, err
+}
+
+// DockerBlobSharing counts how many manifests in a repository still reference each
+// digest. It is what turns "this layer is 400 MB" into "this layer is 400 MB shared
+// with four other tags", which is the honest answer to why deleting a tag reclaims
+// so little.
+func (s *Store) DockerBlobSharing(repositoryID string, digests []string) (map[string]int, error) {
+ sharing := map[string]int{}
+ if len(digests) == 0 {
+ return sharing, nil
+ }
+
+ var rows []struct {
+ ChildDigest string
+ Manifests int
+ }
+ err := s.db.Model(&models.DockerReference{}).
+ Select("child_digest, COUNT(DISTINCT manifest_digest) AS manifests").
+ Where("repository_id = ? AND child_digest IN ?", repositoryID, digests).
+ Group("child_digest").
+ Scan(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+
+ for _, row := range rows {
+ sharing[row.ChildDigest] = row.Manifests
+ }
+ return sharing, nil
+}
+
+// DockerManifestsByDigest reads several parsed manifests at once, which is how the
+// children of a multi-platform index are described without a query each.
+func (s *Store) DockerManifestsByDigest(repositoryID string, digests []string) (map[string]models.DockerManifest, error) {
+ found := map[string]models.DockerManifest{}
+ if len(digests) == 0 {
+ return found, nil
+ }
+
+ var manifests []models.DockerManifest
+ err := s.db.Where("repository_id = ? AND digest IN ?", repositoryID, digests).Find(&manifests).Error
+ if err != nil {
+ return nil, err
+ }
+
+ for _, manifest := range manifests {
+ found[manifest.Digest] = manifest
+ }
+ return found, nil
+}
+
+func (s *Store) DeleteDockerManifest(repositoryID, digest string) error {
+ return s.Transaction(func(tx *Store) error {
+ err := tx.query().Where("repository_id = ? AND manifest_digest = ?", repositoryID, digest).
+ Delete(&models.DockerReference{}).Error
+ if err != nil {
+ return err
+ }
+ return tx.query().Where("repository_id = ? AND digest = ?", repositoryID, digest).
+ Delete(&models.DockerManifest{}).Error
+ })
+}
+
+// DockerImages lists the images a repository holds, rebuilt from the coordinates
+// of its tags, so the catalog needs no table of its own.
+func (s *Store) DockerImages(repositoryID string) ([]string, error) {
+ var rows []struct {
+ Namespace string
+ Name string
+ }
+ err := s.db.Model(&models.Component{}).
+ Select("DISTINCT namespace, name").
+ Where("repository_id = ?", repositoryID).
+ Order("namespace, name").
+ Scan(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+
+ images := make([]string, 0, len(rows))
+ for _, row := range rows {
+ if row.Namespace == "" {
+ images = append(images, row.Name)
+ continue
+ }
+ images = append(images, row.Namespace+"/"+row.Name)
+ }
+ return images, nil
+}
+
+// StoredFile is one asset a sweep has to decide about: its path to delete by, and
+// its digest to match against what is still reachable.
+type StoredFile struct {
+ Path string
+ SHA256 string
+}
+
+// DockerTagRoots names every manifest a tag points at. A tag manifest is the only
+// docker asset linked to a component, and its own SHA256 is the digest it resolves
+// to, so the roots of the reachability graph need no table of their own.
+func (s *Store) DockerTagRoots(repositoryID string) ([]StoredFile, error) {
+ files := []StoredFile{}
+ err := s.db.Model(&models.Asset{}).
+ Select("path, sha256").
+ Where("repository_id = ? AND component_id IS NOT NULL", repositoryID).
+ Scan(&files).Error
+ return files, err
+}
+
+// DockerStoredFiles lists the assets under a path prefix with their digests, which
+// is how the sweep enumerates the blob and digest-manifest stores.
+func (s *Store) DockerStoredFiles(repositoryID, prefix string) ([]StoredFile, error) {
+ files := []StoredFile{}
+
+ bounds, bounded := RangeForPrefix(prefix)
+ query := s.db.Model(&models.Asset{}).
+ Select("path, sha256").
+ Where("repository_id = ?", repositoryID)
+ if bounded {
+ query = query.Where("path >= ? AND path < ?", bounds.Lower, bounds.Upper)
+ }
+
+ return files, query.Scan(&files).Error
+}
+
+// DockerChildrenOf names what the given manifests reference, split by whether the
+// child is another manifest or a blob. The sweep walks the first to find everything
+// reachable and keeps the second.
+func (s *Store) DockerChildrenOf(repositoryID string, manifests []string) (children []string, blobs []string, err error) {
+ if len(manifests) == 0 {
+ return nil, nil, nil
+ }
+
+ var rows []struct {
+ ChildDigest string
+ Kind string
+ }
+ err = s.db.Model(&models.DockerReference{}).
+ Select("child_digest, kind").
+ Where("repository_id = ? AND manifest_digest IN ?", repositoryID, manifests).
+ Scan(&rows).Error
+ if err != nil {
+ return nil, nil, err
+ }
+
+ for _, row := range rows {
+ if row.Kind == models.DockerReferenceManifest {
+ children = append(children, row.ChildDigest)
+ continue
+ }
+ blobs = append(blobs, row.ChildDigest)
+ }
+ return children, blobs, nil
+}
+
+// DeleteDockerManifests drops the parsed records and edges of manifests a sweep has
+// decided are unreachable. The asset holding the bytes is removed separately.
+func (s *Store) DeleteDockerManifests(repositoryID string, digests []string) error {
+ if len(digests) == 0 {
+ return nil
+ }
+
+ return s.Transaction(func(tx *Store) error {
+ err := tx.query().Where("repository_id = ? AND manifest_digest IN ?", repositoryID, digests).
+ Delete(&models.DockerReference{}).Error
+ if err != nil {
+ return err
+ }
+ return tx.query().Where("repository_id = ? AND digest IN ?", repositoryID, digests).
+ Delete(&models.DockerManifest{}).Error
+ })
+}
+
+// RepositoriesOfFormat is what the periodic docker sweep walks. The format is passed
+// in rather than named here, so this package stays free of format constants.
+func (s *Store) RepositoriesOfFormat(repositoryFormat string) ([]models.Repository, error) {
+ var repositories []models.Repository
+ err := s.db.Where("format = ?", repositoryFormat).Order("name").Find(&repositories).Error
+ return repositories, err
+}
+
+// HasPublicRepositories reports whether anyone can read anything of a format
+// without signing in. The registry version check is repository-agnostic, so it
+// is the one endpoint that has to answer before a repository is even named.
+func (s *Store) HasPublicRepositories(repositoryFormat string) (bool, error) {
+ var count int64
+ err := s.db.Model(&models.Repository{}).
+ Where("format = ? AND visibility = ?", repositoryFormat, models.VisibilityPublic).
+ Limit(1).
+ Count(&count).Error
+ return count > 0, err
+}
+
+// RepositoryByNameAndFormat keeps a docker request from resolving onto a Maven
+// repository that happens to share the leading path segment of an image name.
+func (s *Store) RepositoryByNameAndFormat(name, repositoryFormat string) (*models.Repository, error) {
+ var repository models.Repository
+ err := s.db.First(&repository, "name = ? AND format = ?", name, repositoryFormat).Error
+ if err != nil {
+ return nil, err
+ }
+ return &repository, nil
+}
diff --git a/internal/store/models/docker.go b/internal/store/models/docker.go
new file mode 100644
index 0000000..f87288a
--- /dev/null
+++ b/internal/store/models/docker.go
@@ -0,0 +1,83 @@
+package models
+
+const (
+ DockerReferenceConfig = "config"
+ DockerReferenceLayer = "layer"
+ DockerReferenceManifest = "manifest"
+)
+
+// DockerUpload is one in-flight blob push. It lives in the database rather than
+// in memory so a GET on the upload location reports a truthful offset and a
+// restart strands no half-written file that nothing will ever clean up.
+type DockerUpload struct {
+ ID string `gorm:"primaryKey"`
+ RepositoryID string `gorm:"index;not null"`
+ Image string `gorm:"not null"`
+ Size int64 `gorm:"not null;default:0"`
+ UserID *string
+ CreatedAt int64 `gorm:"autoCreateTime:milli"`
+ UpdatedAt int64 `gorm:"autoUpdateTime:milli;index"`
+}
+
+func (DockerUpload) TableName() string { return "docker_uploads" }
+
+// DockerManifest is the parsed form of a manifest document, keyed by the digest
+// of the bytes it was parsed from. The bytes themselves are an asset; this is
+// everything about them worth querying without reopening the file.
+type DockerManifest struct {
+ ID string `gorm:"primaryKey"`
+ RepositoryID string `gorm:"not null;uniqueIndex:docker_manifests_digest,priority:1;index:docker_manifests_image,priority:1"`
+ Digest string `gorm:"not null;uniqueIndex:docker_manifests_digest,priority:2"`
+ MediaType string `gorm:"not null;default:''"`
+ Size int64 `gorm:"not null;default:0"`
+ Namespace string `gorm:"not null;default:'';index:docker_manifests_image,priority:2"`
+ Name string `gorm:"not null;default:'';index:docker_manifests_image,priority:3"`
+ ConfigDigest string `gorm:"not null;default:''"`
+ Architecture string `gorm:"not null;default:''"`
+ OS string `gorm:"column:os;not null;default:''"`
+ Variant string `gorm:"not null;default:''"`
+ // ImageCreated is when the image was built, read from its config blob, as
+ // opposed to CreatedAt which is when this server first saw it.
+ ImageCreated int64 `gorm:"not null;default:0"`
+ LayerCount int `gorm:"not null;default:0"`
+ TotalSize int64 `gorm:"not null;default:0"`
+ Labels string `gorm:"not null;default:''"`
+ Annotations string `gorm:"not null;default:''"`
+ Subject string `gorm:"not null;default:''"`
+ CreatedAt int64 `gorm:"autoCreateTime:milli"`
+ UpdatedAt int64 `gorm:"autoUpdateTime:milli"`
+}
+
+func (DockerManifest) TableName() string { return "docker_manifests" }
+
+func (m DockerManifest) IsIndex() bool { return m.ConfigDigest == "" }
+
+// DockerReference is one edge from a manifest to a blob or to a child manifest.
+// It exists because an asset row can belong to one component, and a layer is
+// shared by every tag that references it, so per-version size and safe deletion
+// cannot be read off the assets alone.
+type DockerReference struct {
+ RepositoryID string `gorm:"primaryKey;index:docker_references_child,priority:1"`
+ ManifestDigest string `gorm:"primaryKey"`
+ // The reverse index answers "which manifests still need this blob", which is
+ // the question both the shared-layer marker and the eventual sweep ask.
+ ChildDigest string `gorm:"primaryKey;index:docker_references_child,priority:2"`
+ Kind string `gorm:"not null"`
+ MediaType string `gorm:"not null;default:''"`
+ Size int64 `gorm:"not null;default:0"`
+ Position int64 `gorm:"not null;default:0"`
+ Platform string `gorm:"not null;default:''"`
+ // URLs holds the newline-separated sources of a nondistributable layer, whose
+ // bytes are never pushed here. Recording them keeps the layer accountable in
+ // the UI instead of showing as a blob that is inexplicably missing.
+ URLs string `gorm:"column:urls;not null;default:''"`
+ // Annotations are the descriptor's own, encoded as JSON. They are what tells an
+ // attestation child of an index apart from a real platform.
+ Annotations string `gorm:"not null;default:''"`
+}
+
+// IsForeign reports a layer whose bytes live outside this registry, which is why
+// it has no asset behind it and must not be counted as stored.
+func (r DockerReference) IsForeign() bool { return r.URLs != "" }
+
+func (DockerReference) TableName() string { return "docker_references" }
diff --git a/internal/store/settings.go b/internal/store/settings.go
index d0477ba..d470632 100644
--- a/internal/store/settings.go
+++ b/internal/store/settings.go
@@ -15,6 +15,10 @@ const (
SettingLogoType = "logo_type"
SettingLogoUpdatedAt = "logo_updated_at"
SettingInitialized = "initialized"
+ // SettingDockerRepository names the repository a bare image reference resolves
+ // to, so "docker pull host/nginx" works without the repository prefix that
+ // name-based routing otherwise requires.
+ SettingDockerRepository = "docker_repository"
DefaultInstanceName = "arca"
DefaultThemeColor = "#4f46e5"
diff --git a/internal/store/store.go b/internal/store/store.go
index cca1bfe..a75cf91 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -98,6 +98,9 @@ func (s *Store) migrate() error {
&models.Asset{},
&models.RemoteMiss{},
&models.TrafficEvent{},
+ &models.DockerUpload{},
+ &models.DockerManifest{},
+ &models.DockerReference{},
)
}