detached d176775

Merge pull request #106 from bnema/feat/native-registry-push

feat: native chunked registry push (Cloudflare-safe)
commit
d176775581e93d3765b7aa639ae98544226f31d1
author
bnema · · 2026-03-14 14:44 UTC
committer
GitHub · · 2026-03-14 14:44 UTC
merge of
d1b6a91 + 51cb687 · diff against the first parent
signature
unsigned
10 files · +1400 -29
M docs/cli/attachments.md +5 -0
@@ -159,6 +159,11 @@ gordon attachments push <image> [options]
159159
160160 `gordon attachments push` pushes attachment images such as databases and caches to the Gordon registry so they are available when routes deploy. It does not trigger deployment. The image must already be configured as an attachment first.
161161
162+It uses the same native chunked upload transport as `gordon push`, sending image
163+layers in 50MB chunks so pushes work through Cloudflare-proxied Gordon
164+instances. Keep the server's `max_blob_chunk_size` larger than the client chunk
165+size; the default `95MB` is compatible.
166+
162167 ### Examples
163168
164169 ```bash
@@ -159,6 +159,11 @@ gordon attachments push <image> [options]
159 159
160 `gordon attachments push` pushes attachment images such as databases and caches to the Gordon registry so they are available when routes deploy. It does not trigger deployment. The image must already be configured as an attachment first.160 `gordon attachments push` pushes attachment images such as databases and caches to the Gordon registry so they are available when routes deploy. It does not trigger deployment. The image must already be configured as an attachment first.
161 161
162+It uses the same native chunked upload transport as `gordon push`, sending image
163+layers in 50MB chunks so pushes work through Cloudflare-proxied Gordon
164+instances. Keep the server's `max_blob_chunk_size` larger than the client chunk
165+size; the default `95MB` is compatible.
166+
162 ### Examples167 ### Examples
163 168
164 ```bash169 ```bash
M docs/cli/push.md +5 -0
@@ -72,6 +72,11 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile
7272 - `gordon push` still requires the route to already exist so it can resolve the target image.
7373 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
7474 install the `docker-buildx-plugin` package.
75+- Gordon uses native registry uploads instead of shelling out to `docker push`.
76+ Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB
77+ per-request limit so proxied pushes keep working. Keep the server's
78+ `max_blob_chunk_size` larger than the client chunk size; the default `95MB`
79+ works out of the box.
7580
7681 ## Related
7782
@@ -72,6 +72,11 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile
72 - `gordon push` still requires the route to already exist so it can resolve the target image.72 - `gordon push` still requires the route to already exist so it can resolve the target image.
73 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,73 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
74 install the `docker-buildx-plugin` package.74 install the `docker-buildx-plugin` package.
75+- Gordon uses native registry uploads instead of shelling out to `docker push`.
76+ Image layers are sent in 50MB chunks, which stays under Cloudflare's 100MB
77+ per-request limit so proxied pushes keep working. Keep the server's
78+ `max_blob_chunk_size` larger than the client chunk size; the default `95MB`
79+ works out of the box.
75 80
76 ## Related81 ## Related
77 82
M docs/config/server.md +3 -2
@@ -220,8 +220,9 @@ max_blob_chunk_size = "1GB" # Allow larger layers
220220
221221 **Behavior:**
222222 - When a blob chunk exceeds the limit, Gordon returns `413 Request Entity Too Large`
223-- Docker/Podman may send a full layer in one request, so set this high enough for your base images
224-- Default (`95MB`) is compatible with Cloudflare and most CDN proxies; increase if pushing very large layers directly
223+- Gordon CLI uploads image layers in 50MB chunks by default, and this setting caps the maximum chunk accepted per request
224+- Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine
225+- Increase it only if you expect larger direct registry uploads from other clients
225226
226227 ## Registry IP Allowlist
227228
@@ -220,8 +220,9 @@ max_blob_chunk_size = "1GB" # Allow larger layers
220 220
221 **Behavior:**221 **Behavior:**
222 - When a blob chunk exceeds the limit, Gordon returns `413 Request Entity Too Large`222 - When a blob chunk exceeds the limit, Gordon returns `413 Request Entity Too Large`
223-- Docker/Podman may send a full layer in one request, so set this high enough for your base images223+- Gordon CLI uploads image layers in 50MB chunks by default, and this setting caps the maximum chunk accepted per request
224-- Default (`95MB`) is compatible with Cloudflare and most CDN proxies; increase if pushing very large layers directly224+- Keep this below Cloudflare's 100MB per-request limit when operating behind Cloudflare; the default (`95MB`) is fine
225+- Increase it only if you expect larger direct registry uploads from other clients
225 226
226 ## Registry IP Allowlist227 ## Registry IP Allowlist
227 228
M go.mod +11 -4
@@ -10,6 +10,7 @@ require (
1010 github.com/docker/docker v28.5.2+incompatible
1111 github.com/docker/go-connections v0.5.0
1212 github.com/golang-jwt/jwt/v5 v5.3.1
13+ github.com/google/go-containerregistry v0.21.2
1314 github.com/google/uuid v1.6.0
1415 github.com/mattn/go-runewidth v0.0.21
1516 github.com/rivo/uniseg v0.4.7
@@ -39,20 +40,29 @@ require (
3940 github.com/clipperhouse/displaywidth v0.9.0 // indirect
4041 github.com/clipperhouse/stringish v0.1.1 // indirect
4142 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
43+ github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
44+ github.com/docker/cli v29.2.1+incompatible // indirect
45+ github.com/docker/docker-credential-helpers v0.9.3 // indirect
4246 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
4347 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
48+ github.com/klauspost/compress v1.18.4 // indirect
4449 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
4550 github.com/mattn/go-localereader v0.0.1 // indirect
51+ github.com/mitchellh/go-homedir v1.1.0 // indirect
52+ github.com/moby/sys/sequential v0.6.0 // indirect
4653 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
4754 github.com/muesli/cancelreader v0.2.2 // indirect
4855 github.com/muesli/termenv v0.16.0 // indirect
4956 github.com/rs/zerolog v1.34.0 // indirect
57+ github.com/sirupsen/logrus v1.9.3 // indirect
58+ github.com/vbatts/tar-split v0.12.2 // indirect
5059 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
5160 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
5261 go.opentelemetry.io/otel/log v0.18.0 // indirect
5362 go.opentelemetry.io/proto/otlp v1.9.0 // indirect
5463 go.yaml.in/yaml/v3 v3.0.4 // indirect
5564 golang.org/x/net v0.49.0 // indirect
65+ golang.org/x/sync v0.19.0 // indirect
5666 google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
5767 google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
5868 google.golang.org/grpc v1.78.0 // indirect
@@ -68,10 +78,9 @@ require (
6878 )
6979
7080 require (
71- github.com/Microsoft/go-winio v0.4.14 // indirect
81+ github.com/Microsoft/go-winio v0.6.2 // indirect
7282 github.com/containerd/errdefs v1.0.0
7383 github.com/containerd/errdefs/pkg v0.3.0 // indirect
74- github.com/containerd/log v0.1.0 // indirect
7584 github.com/distribution/reference v0.6.0 // indirect
7685 github.com/docker/go-units v0.5.0 // indirect
7786 github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -83,9 +92,7 @@ require (
8392 github.com/mattn/go-colorable v0.1.14 // indirect
8493 github.com/mattn/go-isatty v0.0.20 // indirect
8594 github.com/moby/docker-image-spec v1.3.1 // indirect
86- github.com/moby/sys/atomicwriter v0.1.0 // indirect
8795 github.com/moby/term v0.5.2 // indirect
88- github.com/morikuni/aec v1.0.0 // indirect
8996 github.com/opencontainers/go-digest v1.0.0 // indirect
9097 github.com/opencontainers/image-spec v1.1.1 // indirect
9198 github.com/pelletier/go-toml/v2 v2.2.4
@@ -10,6 +10,7 @@ require (
10 github.com/docker/docker v28.5.2+incompatible10 github.com/docker/docker v28.5.2+incompatible
11 github.com/docker/go-connections v0.5.011 github.com/docker/go-connections v0.5.0
12 github.com/golang-jwt/jwt/v5 v5.3.112 github.com/golang-jwt/jwt/v5 v5.3.1
13+ github.com/google/go-containerregistry v0.21.2
13 github.com/google/uuid v1.6.014 github.com/google/uuid v1.6.0
14 github.com/mattn/go-runewidth v0.0.2115 github.com/mattn/go-runewidth v0.0.21
15 github.com/rivo/uniseg v0.4.716 github.com/rivo/uniseg v0.4.7
@@ -39,20 +40,29 @@ require (
39 github.com/clipperhouse/displaywidth v0.9.0 // indirect40 github.com/clipperhouse/displaywidth v0.9.0 // indirect
40 github.com/clipperhouse/stringish v0.1.1 // indirect41 github.com/clipperhouse/stringish v0.1.1 // indirect
41 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect42 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
43+ github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
44+ github.com/docker/cli v29.2.1+incompatible // indirect
45+ github.com/docker/docker-credential-helpers v0.9.3 // indirect
42 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect46 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
43 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect47 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
48+ github.com/klauspost/compress v1.18.4 // indirect
44 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect49 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
45 github.com/mattn/go-localereader v0.0.1 // indirect50 github.com/mattn/go-localereader v0.0.1 // indirect
51+ github.com/mitchellh/go-homedir v1.1.0 // indirect
52+ github.com/moby/sys/sequential v0.6.0 // indirect
46 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect53 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
47 github.com/muesli/cancelreader v0.2.2 // indirect54 github.com/muesli/cancelreader v0.2.2 // indirect
48 github.com/muesli/termenv v0.16.0 // indirect55 github.com/muesli/termenv v0.16.0 // indirect
49 github.com/rs/zerolog v1.34.0 // indirect56 github.com/rs/zerolog v1.34.0 // indirect
57+ github.com/sirupsen/logrus v1.9.3 // indirect
58+ github.com/vbatts/tar-split v0.12.2 // indirect
50 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect59 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
51 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect60 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
52 go.opentelemetry.io/otel/log v0.18.0 // indirect61 go.opentelemetry.io/otel/log v0.18.0 // indirect
53 go.opentelemetry.io/proto/otlp v1.9.0 // indirect62 go.opentelemetry.io/proto/otlp v1.9.0 // indirect
54 go.yaml.in/yaml/v3 v3.0.4 // indirect63 go.yaml.in/yaml/v3 v3.0.4 // indirect
55 golang.org/x/net v0.49.0 // indirect64 golang.org/x/net v0.49.0 // indirect
65+ golang.org/x/sync v0.19.0 // indirect
56 google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect66 google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
57 google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect67 google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
58 google.golang.org/grpc v1.78.0 // indirect68 google.golang.org/grpc v1.78.0 // indirect
@@ -68,10 +78,9 @@ require (
68 )78 )
69 79
70 require (80 require (
71- github.com/Microsoft/go-winio v0.4.14 // indirect81+ github.com/Microsoft/go-winio v0.6.2 // indirect
72 github.com/containerd/errdefs v1.0.082 github.com/containerd/errdefs v1.0.0
73 github.com/containerd/errdefs/pkg v0.3.0 // indirect83 github.com/containerd/errdefs/pkg v0.3.0 // indirect
74- github.com/containerd/log v0.1.0 // indirect
75 github.com/distribution/reference v0.6.0 // indirect84 github.com/distribution/reference v0.6.0 // indirect
76 github.com/docker/go-units v0.5.0 // indirect85 github.com/docker/go-units v0.5.0 // indirect
77 github.com/felixge/httpsnoop v1.0.4 // indirect86 github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -83,9 +92,7 @@ require (
83 github.com/mattn/go-colorable v0.1.14 // indirect92 github.com/mattn/go-colorable v0.1.14 // indirect
84 github.com/mattn/go-isatty v0.0.20 // indirect93 github.com/mattn/go-isatty v0.0.20 // indirect
85 github.com/moby/docker-image-spec v1.3.1 // indirect94 github.com/moby/docker-image-spec v1.3.1 // indirect
86- github.com/moby/sys/atomicwriter v0.1.0 // indirect
87 github.com/moby/term v0.5.2 // indirect95 github.com/moby/term v0.5.2 // indirect
88- github.com/morikuni/aec v1.0.0 // indirect
89 github.com/opencontainers/go-digest v1.0.0 // indirect96 github.com/opencontainers/go-digest v1.0.0 // indirect
90 github.com/opencontainers/image-spec v1.1.1 // indirect97 github.com/opencontainers/image-spec v1.1.1 // indirect
91 github.com/pelletier/go-toml/v2 v2.2.498 github.com/pelletier/go-toml/v2 v2.2.4
M go.sum +25 -9 generated file: collapsed
M internal/adapters/in/cli/push.go +20 -14
@@ -18,9 +18,16 @@ import (
1818 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1919 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
2020 "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/pkg/registrypush"
2122 "github.com/bnema/gordon/pkg/validation"
2223 )
2324
25+var (
26+ dockerTagFn = dockerTag
27+ dockerPushFn = dockerPush
28+ dockerImageExistsFn = dockerImageExists
29+)
30+
2431 func newPushCmd() *cobra.Command {
2532 var (
2633 noDeploy bool
@@ -473,9 +480,8 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
473480 }
474481
475482 // Build and load into local daemon (NOT --push).
476- // BuildKit's --push uses monolithic blob uploads that exceed
477- // Cloudflare's 100MB per-request limit. Loading locally then
478- // using docker push gives us chunked uploads (~5MB per request).
483+ // The native registry push client handles chunked uploads to stay
484+ // within Cloudflare's 100MB per-request limit.
479485 fmt.Println("\nBuilding image...")
480486 buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
481487 buildCmd.Env = os.Environ() // VERSION is now passed as --build-arg VERSION=<value>
@@ -486,11 +492,11 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
486492 }
487493
488494 fmt.Println("Pushing...")
489- if err := dockerPush(ctx, latestRef); err != nil {
495+ if err := dockerPushFn(ctx, latestRef); err != nil {
490496 return fmt.Errorf("failed to push %s: %w", latestRef, err)
491497 }
492498 if version != "latest" {
493- if err := dockerPush(ctx, versionRef); err != nil {
499+ if err := dockerPushFn(ctx, versionRef); err != nil {
494500 return fmt.Errorf("failed to push %s: %w", versionRef, err)
495501 }
496502 }
@@ -552,26 +558,26 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l
552558 localImage := fmt.Sprintf("%s/%s", registry, imageName)
553559
554560 fmt.Println("\nChecking local image...")
555- if !dockerImageExists(ctx, localImage) {
561+ if !dockerImageExistsFn(ctx, localImage) {
556562 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
557563 }
558564
559565 fmt.Println("Tagging...")
560- if err := dockerTag(ctx, localImage, versionRef); err != nil {
566+ if err := dockerTagFn(ctx, localImage, versionRef); err != nil {
561567 return fmt.Errorf("failed to tag %s: %w", versionRef, err)
562568 }
563569 if version != "latest" {
564- if err := dockerTag(ctx, localImage, latestRef); err != nil {
570+ if err := dockerTagFn(ctx, localImage, latestRef); err != nil {
565571 return fmt.Errorf("failed to tag %s: %w", latestRef, err)
566572 }
567573 }
568574
569575 fmt.Println("Pushing...")
570- if err := dockerPush(ctx, versionRef); err != nil {
576+ if err := dockerPushFn(ctx, versionRef); err != nil {
571577 return fmt.Errorf("failed to push %s: %w", versionRef, err)
572578 }
573579 if version != "latest" {
574- if err := dockerPush(ctx, latestRef); err != nil {
580+ if err := dockerPushFn(ctx, latestRef); err != nil {
575581 return fmt.Errorf("failed to push %s: %w", latestRef, err)
576582 }
577583 }
@@ -744,10 +750,10 @@ func dockerTag(ctx context.Context, src, dst string) error {
744750 }
745751
746752 func dockerPush(ctx context.Context, ref string) error {
747- cmd := exec.CommandContext(ctx, "docker", "push", ref) //nolint:gosec // binary is constant; image ref validated by OCI ref parser
748- cmd.Stdout = os.Stdout
749- cmd.Stderr = os.Stderr
750- return cmd.Run()
753+ pusher := registrypush.New(
754+ registrypush.WithProgress(os.Stderr),
755+ )
756+ return pusher.Push(ctx, ref)
751757 }
752758
753759 func dockerImageExists(ctx context.Context, image string) bool {
@@ -18,9 +18,16 @@ import (
18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
20 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/pkg/registrypush"
21 "github.com/bnema/gordon/pkg/validation"22 "github.com/bnema/gordon/pkg/validation"
22 )23 )
23 24
25+var (
26+ dockerTagFn = dockerTag
27+ dockerPushFn = dockerPush
28+ dockerImageExistsFn = dockerImageExists
29+)
30+
24 func newPushCmd() *cobra.Command {31 func newPushCmd() *cobra.Command {
25 var (32 var (
26 noDeploy bool33 noDeploy bool
@@ -473,9 +480,8 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
473 }480 }
474 481
475 // Build and load into local daemon (NOT --push).482 // Build and load into local daemon (NOT --push).
476- // BuildKit's --push uses monolithic blob uploads that exceed483+ // The native registry push client handles chunked uploads to stay
477- // Cloudflare's 100MB per-request limit. Loading locally then484+ // within Cloudflare's 100MB per-request limit.
478- // using docker push gives us chunked uploads (~5MB per request).
479 fmt.Println("\nBuilding image...")485 fmt.Println("\nBuilding image...")
480 buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204486 buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
481 buildCmd.Env = os.Environ() // VERSION is now passed as --build-arg VERSION=<value>487 buildCmd.Env = os.Environ() // VERSION is now passed as --build-arg VERSION=<value>
@@ -486,11 +492,11 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
486 }492 }
487 493
488 fmt.Println("Pushing...")494 fmt.Println("Pushing...")
489- if err := dockerPush(ctx, latestRef); err != nil {495+ if err := dockerPushFn(ctx, latestRef); err != nil {
490 return fmt.Errorf("failed to push %s: %w", latestRef, err)496 return fmt.Errorf("failed to push %s: %w", latestRef, err)
491 }497 }
492 if version != "latest" {498 if version != "latest" {
493- if err := dockerPush(ctx, versionRef); err != nil {499+ if err := dockerPushFn(ctx, versionRef); err != nil {
494 return fmt.Errorf("failed to push %s: %w", versionRef, err)500 return fmt.Errorf("failed to push %s: %w", versionRef, err)
495 }501 }
496 }502 }
@@ -552,26 +558,26 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l
552 localImage := fmt.Sprintf("%s/%s", registry, imageName)558 localImage := fmt.Sprintf("%s/%s", registry, imageName)
553 559
554 fmt.Println("\nChecking local image...")560 fmt.Println("\nChecking local image...")
555- if !dockerImageExists(ctx, localImage) {561+ if !dockerImageExistsFn(ctx, localImage) {
556 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)562 return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
557 }563 }
558 564
559 fmt.Println("Tagging...")565 fmt.Println("Tagging...")
560- if err := dockerTag(ctx, localImage, versionRef); err != nil {566+ if err := dockerTagFn(ctx, localImage, versionRef); err != nil {
561 return fmt.Errorf("failed to tag %s: %w", versionRef, err)567 return fmt.Errorf("failed to tag %s: %w", versionRef, err)
562 }568 }
563 if version != "latest" {569 if version != "latest" {
564- if err := dockerTag(ctx, localImage, latestRef); err != nil {570+ if err := dockerTagFn(ctx, localImage, latestRef); err != nil {
565 return fmt.Errorf("failed to tag %s: %w", latestRef, err)571 return fmt.Errorf("failed to tag %s: %w", latestRef, err)
566 }572 }
567 }573 }
568 574
569 fmt.Println("Pushing...")575 fmt.Println("Pushing...")
570- if err := dockerPush(ctx, versionRef); err != nil {576+ if err := dockerPushFn(ctx, versionRef); err != nil {
571 return fmt.Errorf("failed to push %s: %w", versionRef, err)577 return fmt.Errorf("failed to push %s: %w", versionRef, err)
572 }578 }
573 if version != "latest" {579 if version != "latest" {
574- if err := dockerPush(ctx, latestRef); err != nil {580+ if err := dockerPushFn(ctx, latestRef); err != nil {
575 return fmt.Errorf("failed to push %s: %w", latestRef, err)581 return fmt.Errorf("failed to push %s: %w", latestRef, err)
576 }582 }
577 }583 }
@@ -744,10 +750,10 @@ func dockerTag(ctx context.Context, src, dst string) error {
744 }750 }
745 751
746 func dockerPush(ctx context.Context, ref string) error {752 func dockerPush(ctx context.Context, ref string) error {
747- cmd := exec.CommandContext(ctx, "docker", "push", ref) //nolint:gosec // binary is constant; image ref validated by OCI ref parser753+ pusher := registrypush.New(
748- cmd.Stdout = os.Stdout754+ registrypush.WithProgress(os.Stderr),
749- cmd.Stderr = os.Stderr755+ )
750- return cmd.Run()756+ return pusher.Push(ctx, ref)
751 }757 }
752 758
753 func dockerImageExists(ctx context.Context, image string) bool {759 func dockerImageExists(ctx context.Context, image string) bool {
A internal/adapters/in/cli/push_integration_test.go +135 -0
@@ -0,0 +1,135 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ v1 "github.com/google/go-containerregistry/pkg/v1"
13+ "github.com/google/go-containerregistry/pkg/v1/random"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ registryhttp "github.com/bnema/gordon/internal/adapters/in/http/registry"
18+ "github.com/bnema/gordon/internal/adapters/out/filesystem"
19+ "github.com/bnema/gordon/internal/domain"
20+ registrysvc "github.com/bnema/gordon/internal/usecase/registry"
21+ "github.com/bnema/gordon/pkg/registrypush"
22+)
23+
24+func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
25+ t.Parallel()
26+
27+ ctx := context.Background()
28+ harness := newCLITestRegistry(t, 95*1024*1024)
29+ defer harness.Close()
30+
31+ img, err := random.Image(512, 1)
32+ require.NoError(t, err)
33+
34+ originalPush := dockerPushFn
35+ originalTag := dockerTagFn
36+ originalImageExists := dockerImageExistsFn
37+ t.Cleanup(func() {
38+ dockerPushFn = originalPush
39+ dockerTagFn = originalTag
40+ dockerImageExistsFn = originalImageExists
41+ })
42+
43+ dockerPushFn = func(ctx context.Context, ref string) error {
44+ pusher := registrypush.New(
45+ registrypush.WithChunkSize(256),
46+ registrypush.WithTransport(harness.server.Client().Transport),
47+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
48+ return img, nil
49+ }),
50+ )
51+ return pusher.Push(ctx, ref)
52+ }
53+ dockerTagFn = func(context.Context, string, string) error {
54+ return nil
55+ }
56+ dockerImageExistsFn = func(context.Context, string) bool {
57+ return true
58+ }
59+
60+ registry := strings.TrimPrefix(harness.server.URL, "http://")
61+ versionRef := registry + "/testapp:v1.0.0"
62+ latestRef := registry + "/testapp:latest"
63+
64+ err = tagAndPush(ctx, registry, "testapp", "v1.0.0", versionRef, latestRef)
65+ require.NoError(t, err)
66+
67+ harness.RequireManifest(t, "v1.0.0")
68+ harness.RequireManifest(t, "latest")
69+ harness.RequireBlobs(ctx, t, img)
70+}
71+
72+type cliTestRegistry struct {
73+ server *httptest.Server
74+}
75+
76+func newCLITestRegistry(t *testing.T, maxBlobChunkSize int64) *cliTestRegistry {
77+ t.Helper()
78+
79+ log := zerowrap.Default()
80+ rootDir := t.TempDir()
81+
82+ blobStore, err := filesystem.NewBlobStorage(rootDir, log)
83+ require.NoError(t, err)
84+
85+ manifestStore, err := filesystem.NewManifestStorage(rootDir, log)
86+ require.NoError(t, err)
87+
88+ svc := registrysvc.NewService(blobStore, manifestStore, cliNoopEventPublisher{})
89+ handler := registryhttp.NewHandler(svc, log, maxBlobChunkSize)
90+
91+ return &cliTestRegistry{server: httptest.NewServer(handler)}
92+}
93+
94+func (h *cliTestRegistry) Close() {
95+ h.server.Close()
96+}
97+
98+func (h *cliTestRegistry) RequireManifest(t *testing.T, tag string) {
99+ t.Helper()
100+
101+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/v2/testapp/manifests/%s", h.server.URL, tag), nil)
102+ require.NoError(t, err)
103+
104+ resp, err := h.server.Client().Do(req)
105+ require.NoError(t, err)
106+ defer resp.Body.Close()
107+
108+ assert.Equal(t, http.StatusOK, resp.StatusCode)
109+}
110+
111+func (h *cliTestRegistry) RequireBlobs(ctx context.Context, t *testing.T, img v1.Image) {
112+ t.Helper()
113+
114+ layers, err := img.Layers()
115+ require.NoError(t, err)
116+
117+ for _, layer := range layers {
118+ digest, digestErr := layer.Digest()
119+ require.NoError(t, digestErr)
120+
121+ req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v2/testapp/blobs/%s", h.server.URL, digest.String()), nil)
122+ require.NoError(t, reqErr)
123+
124+ resp, respErr := h.server.Client().Do(req)
125+ require.NoError(t, respErr)
126+ assert.Equal(t, http.StatusOK, resp.StatusCode)
127+ require.NoError(t, resp.Body.Close())
128+ }
129+}
130+
131+type cliNoopEventPublisher struct{}
132+
133+func (cliNoopEventPublisher) Publish(domain.EventType, any) error {
134+ return nil
135+}
@@ -0,0 +1,135 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ v1 "github.com/google/go-containerregistry/pkg/v1"
13+ "github.com/google/go-containerregistry/pkg/v1/random"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ registryhttp "github.com/bnema/gordon/internal/adapters/in/http/registry"
18+ "github.com/bnema/gordon/internal/adapters/out/filesystem"
19+ "github.com/bnema/gordon/internal/domain"
20+ registrysvc "github.com/bnema/gordon/internal/usecase/registry"
21+ "github.com/bnema/gordon/pkg/registrypush"
22+)
23+
24+func TestIntegration_TagAndPush_NativeRegistry(t *testing.T) {
25+ t.Parallel()
26+
27+ ctx := context.Background()
28+ harness := newCLITestRegistry(t, 95*1024*1024)
29+ defer harness.Close()
30+
31+ img, err := random.Image(512, 1)
32+ require.NoError(t, err)
33+
34+ originalPush := dockerPushFn
35+ originalTag := dockerTagFn
36+ originalImageExists := dockerImageExistsFn
37+ t.Cleanup(func() {
38+ dockerPushFn = originalPush
39+ dockerTagFn = originalTag
40+ dockerImageExistsFn = originalImageExists
41+ })
42+
43+ dockerPushFn = func(ctx context.Context, ref string) error {
44+ pusher := registrypush.New(
45+ registrypush.WithChunkSize(256),
46+ registrypush.WithTransport(harness.server.Client().Transport),
47+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
48+ return img, nil
49+ }),
50+ )
51+ return pusher.Push(ctx, ref)
52+ }
53+ dockerTagFn = func(context.Context, string, string) error {
54+ return nil
55+ }
56+ dockerImageExistsFn = func(context.Context, string) bool {
57+ return true
58+ }
59+
60+ registry := strings.TrimPrefix(harness.server.URL, "http://")
61+ versionRef := registry + "/testapp:v1.0.0"
62+ latestRef := registry + "/testapp:latest"
63+
64+ err = tagAndPush(ctx, registry, "testapp", "v1.0.0", versionRef, latestRef)
65+ require.NoError(t, err)
66+
67+ harness.RequireManifest(t, "v1.0.0")
68+ harness.RequireManifest(t, "latest")
69+ harness.RequireBlobs(ctx, t, img)
70+}
71+
72+type cliTestRegistry struct {
73+ server *httptest.Server
74+}
75+
76+func newCLITestRegistry(t *testing.T, maxBlobChunkSize int64) *cliTestRegistry {
77+ t.Helper()
78+
79+ log := zerowrap.Default()
80+ rootDir := t.TempDir()
81+
82+ blobStore, err := filesystem.NewBlobStorage(rootDir, log)
83+ require.NoError(t, err)
84+
85+ manifestStore, err := filesystem.NewManifestStorage(rootDir, log)
86+ require.NoError(t, err)
87+
88+ svc := registrysvc.NewService(blobStore, manifestStore, cliNoopEventPublisher{})
89+ handler := registryhttp.NewHandler(svc, log, maxBlobChunkSize)
90+
91+ return &cliTestRegistry{server: httptest.NewServer(handler)}
92+}
93+
94+func (h *cliTestRegistry) Close() {
95+ h.server.Close()
96+}
97+
98+func (h *cliTestRegistry) RequireManifest(t *testing.T, tag string) {
99+ t.Helper()
100+
101+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/v2/testapp/manifests/%s", h.server.URL, tag), nil)
102+ require.NoError(t, err)
103+
104+ resp, err := h.server.Client().Do(req)
105+ require.NoError(t, err)
106+ defer resp.Body.Close()
107+
108+ assert.Equal(t, http.StatusOK, resp.StatusCode)
109+}
110+
111+func (h *cliTestRegistry) RequireBlobs(ctx context.Context, t *testing.T, img v1.Image) {
112+ t.Helper()
113+
114+ layers, err := img.Layers()
115+ require.NoError(t, err)
116+
117+ for _, layer := range layers {
118+ digest, digestErr := layer.Digest()
119+ require.NoError(t, digestErr)
120+
121+ req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v2/testapp/blobs/%s", h.server.URL, digest.String()), nil)
122+ require.NoError(t, reqErr)
123+
124+ resp, respErr := h.server.Client().Do(req)
125+ require.NoError(t, respErr)
126+ assert.Equal(t, http.StatusOK, resp.StatusCode)
127+ require.NoError(t, resp.Body.Close())
128+ }
129+}
130+
131+type cliNoopEventPublisher struct{}
132+
133+func (cliNoopEventPublisher) Publish(domain.EventType, any) error {
134+ return nil
135+}
A pkg/registrypush/integration_test.go +203 -0
@@ -0,0 +1,203 @@
1+package registrypush_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+
12+ "github.com/bnema/zerowrap"
13+ v1 "github.com/google/go-containerregistry/pkg/v1"
14+ "github.com/google/go-containerregistry/pkg/v1/random"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ registryhttp "github.com/bnema/gordon/internal/adapters/in/http/registry"
19+ "github.com/bnema/gordon/internal/adapters/out/filesystem"
20+ "github.com/bnema/gordon/internal/domain"
21+ registrysvc "github.com/bnema/gordon/internal/usecase/registry"
22+ "github.com/bnema/gordon/pkg/registrypush"
23+)
24+
25+func TestIntegration_PushSingleLayerImage(t *testing.T) {
26+ t.Parallel()
27+
28+ ctx := context.Background()
29+ img, err := random.Image(512, 1)
30+ require.NoError(t, err)
31+
32+ harness := newRegistryHarness(t, registryhttp.DefaultMaxBlobChunkSize)
33+ defer harness.Close()
34+
35+ err = harness.Push(ctx, img, registrypush.WithChunkSize(256))
36+ require.NoError(t, err)
37+
38+ harness.RequireManifestAndBlobs(ctx, t, img)
39+}
40+
41+func TestIntegration_PushMultiLayerChunked(t *testing.T) {
42+ t.Parallel()
43+
44+ ctx := context.Background()
45+ img, err := random.Image(4096, 3)
46+ require.NoError(t, err)
47+
48+ harness := newRegistryHarness(t, 1025)
49+ defer harness.Close()
50+
51+ err = harness.Push(ctx, img, registrypush.WithChunkSize(1024))
52+ require.NoError(t, err)
53+
54+ harness.RequireManifestAndBlobs(ctx, t, img)
55+}
56+
57+func TestIntegration_PushSkipsExistingBlobs(t *testing.T) {
58+ t.Parallel()
59+
60+ ctx := context.Background()
61+ img, err := random.Image(1024, 2)
62+ require.NoError(t, err)
63+
64+ harness := newRegistryHarness(t, registryhttp.DefaultMaxBlobChunkSize)
65+ defer harness.Close()
66+
67+ err = harness.Push(ctx, img, registrypush.WithChunkSize(256))
68+ require.NoError(t, err)
69+
70+ err = harness.Push(ctx, img, registrypush.WithChunkSize(256))
71+ require.NoError(t, err)
72+}
73+
74+func TestIntegration_ServerRejectsOversizedChunk(t *testing.T) {
75+ t.Parallel()
76+
77+ ctx := context.Background()
78+ img, err := random.Image(4096, 1)
79+ require.NoError(t, err)
80+
81+ harness := newRegistryHarness(t, 512)
82+ defer harness.Close()
83+
84+ err = harness.Push(ctx, img, registrypush.WithChunkSize(4096))
85+ require.Error(t, err)
86+ assert.Contains(t, err.Error(), "413")
87+}
88+
89+func TestIntegration_ManifestRoundtrip(t *testing.T) {
90+ t.Parallel()
91+
92+ ctx := context.Background()
93+ img, err := random.Image(1024, 2)
94+ require.NoError(t, err)
95+
96+ harness := newRegistryHarness(t, registryhttp.DefaultMaxBlobChunkSize)
97+ defer harness.Close()
98+
99+ err = harness.Push(ctx, img, registrypush.WithChunkSize(512))
100+ require.NoError(t, err)
101+
102+ expectedManifest, err := img.RawManifest()
103+ require.NoError(t, err)
104+
105+ expectedMediaType, err := img.MediaType()
106+ require.NoError(t, err)
107+
108+ resp := harness.GetManifest(t)
109+ body, readErr := io.ReadAll(resp.Body)
110+ require.NoError(t, readErr)
111+ require.NoError(t, resp.Body.Close())
112+
113+ assert.Equal(t, http.StatusOK, resp.StatusCode)
114+ assert.Equal(t, expectedManifest, body)
115+ assert.Equal(t, string(expectedMediaType), resp.Header.Get("Content-Type"))
116+}
117+
118+type registryHarness struct {
119+ server *httptest.Server
120+ ref string
121+}
122+
123+func newRegistryHarness(t *testing.T, maxBlobChunkSize int64) *registryHarness {
124+ t.Helper()
125+
126+ log := zerowrap.Default()
127+ rootDir := t.TempDir()
128+
129+ blobStore, err := filesystem.NewBlobStorage(rootDir, log)
130+ require.NoError(t, err)
131+
132+ manifestStore, err := filesystem.NewManifestStorage(rootDir, log)
133+ require.NoError(t, err)
134+
135+ svc := registrysvc.NewService(blobStore, manifestStore, noopEventPublisher{})
136+ handler := registryhttp.NewHandler(svc, log, maxBlobChunkSize)
137+ server := httptest.NewServer(handler)
138+
139+ host := strings.TrimPrefix(server.URL, "http://")
140+
141+ return &registryHarness{
142+ server: server,
143+ ref: host + "/testapp:v1.0.0",
144+ }
145+}
146+
147+func (h *registryHarness) Close() {
148+ h.server.Close()
149+}
150+
151+func (h *registryHarness) Push(ctx context.Context, img v1.Image, opts ...registrypush.Option) error {
152+ allOpts := append([]registrypush.Option{
153+ registrypush.WithTransport(h.server.Client().Transport),
154+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
155+ return img, nil
156+ }),
157+ }, opts...)
158+
159+ pusher := registrypush.New(allOpts...)
160+ return pusher.Push(ctx, h.ref)
161+}
162+
163+func (h *registryHarness) GetManifest(t *testing.T) *http.Response {
164+ t.Helper()
165+
166+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/v2/testapp/manifests/v1.0.0", h.server.URL), nil)
167+ require.NoError(t, err)
168+
169+ resp, err := h.server.Client().Do(req)
170+ require.NoError(t, err)
171+
172+ return resp
173+}
174+
175+func (h *registryHarness) RequireManifestAndBlobs(ctx context.Context, t *testing.T, img v1.Image) {
176+ t.Helper()
177+
178+ manifestResp := h.GetManifest(t)
179+ require.Equal(t, http.StatusOK, manifestResp.StatusCode)
180+ require.NoError(t, manifestResp.Body.Close())
181+
182+ layers, err := img.Layers()
183+ require.NoError(t, err)
184+
185+ for _, layer := range layers {
186+ digest, digestErr := layer.Digest()
187+ require.NoError(t, digestErr)
188+
189+ req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v2/testapp/blobs/%s", h.server.URL, digest.String()), nil)
190+ require.NoError(t, reqErr)
191+
192+ resp, respErr := h.server.Client().Do(req)
193+ require.NoError(t, respErr)
194+ assert.Equal(t, http.StatusOK, resp.StatusCode)
195+ require.NoError(t, resp.Body.Close())
196+ }
197+}
198+
199+type noopEventPublisher struct{}
200+
201+func (noopEventPublisher) Publish(domain.EventType, any) error {
202+ return nil
203+}
@@ -0,0 +1,203 @@
1+package registrypush_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+
12+ "github.com/bnema/zerowrap"
13+ v1 "github.com/google/go-containerregistry/pkg/v1"
14+ "github.com/google/go-containerregistry/pkg/v1/random"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ registryhttp "github.com/bnema/gordon/internal/adapters/in/http/registry"
19+ "github.com/bnema/gordon/internal/adapters/out/filesystem"
20+ "github.com/bnema/gordon/internal/domain"
21+ registrysvc "github.com/bnema/gordon/internal/usecase/registry"
22+ "github.com/bnema/gordon/pkg/registrypush"
23+)
24+
25+func TestIntegration_PushSingleLayerImage(t *testing.T) {
26+ t.Parallel()
27+
28+ ctx := context.Background()
29+ img, err := random.Image(512, 1)
30+ require.NoError(t, err)
31+
32+ harness := newRegistryHarness(t, registryhttp.DefaultMaxBlobChunkSize)
33+ defer harness.Close()
34+
35+ err = harness.Push(ctx, img, registrypush.WithChunkSize(256))
36+ require.NoError(t, err)
37+
38+ harness.RequireManifestAndBlobs(ctx, t, img)
39+}
40+
41+func TestIntegration_PushMultiLayerChunked(t *testing.T) {
42+ t.Parallel()
43+
44+ ctx := context.Background()
45+ img, err := random.Image(4096, 3)
46+ require.NoError(t, err)
47+
48+ harness := newRegistryHarness(t, 1025)
49+ defer harness.Close()
50+
51+ err = harness.Push(ctx, img, registrypush.WithChunkSize(1024))
52+ require.NoError(t, err)
53+
54+ harness.RequireManifestAndBlobs(ctx, t, img)
55+}
56+
57+func TestIntegration_PushSkipsExistingBlobs(t *testing.T) {
58+ t.Parallel()
59+
60+ ctx := context.Background()
61+ img, err := random.Image(1024, 2)
62+ require.NoError(t, err)
63+
64+ harness := newRegistryHarness(t, registryhttp.DefaultMaxBlobChunkSize)
65+ defer harness.Close()
66+
67+ err = harness.Push(ctx, img, registrypush.WithChunkSize(256))
68+ require.NoError(t, err)
69+
70+ err = harness.Push(ctx, img, registrypush.WithChunkSize(256))
71+ require.NoError(t, err)
72+}
73+
74+func TestIntegration_ServerRejectsOversizedChunk(t *testing.T) {
75+ t.Parallel()
76+
77+ ctx := context.Background()
78+ img, err := random.Image(4096, 1)
79+ require.NoError(t, err)
80+
81+ harness := newRegistryHarness(t, 512)
82+ defer harness.Close()
83+
84+ err = harness.Push(ctx, img, registrypush.WithChunkSize(4096))
85+ require.Error(t, err)
86+ assert.Contains(t, err.Error(), "413")
87+}
88+
89+func TestIntegration_ManifestRoundtrip(t *testing.T) {
90+ t.Parallel()
91+
92+ ctx := context.Background()
93+ img, err := random.Image(1024, 2)
94+ require.NoError(t, err)
95+
96+ harness := newRegistryHarness(t, registryhttp.DefaultMaxBlobChunkSize)
97+ defer harness.Close()
98+
99+ err = harness.Push(ctx, img, registrypush.WithChunkSize(512))
100+ require.NoError(t, err)
101+
102+ expectedManifest, err := img.RawManifest()
103+ require.NoError(t, err)
104+
105+ expectedMediaType, err := img.MediaType()
106+ require.NoError(t, err)
107+
108+ resp := harness.GetManifest(t)
109+ body, readErr := io.ReadAll(resp.Body)
110+ require.NoError(t, readErr)
111+ require.NoError(t, resp.Body.Close())
112+
113+ assert.Equal(t, http.StatusOK, resp.StatusCode)
114+ assert.Equal(t, expectedManifest, body)
115+ assert.Equal(t, string(expectedMediaType), resp.Header.Get("Content-Type"))
116+}
117+
118+type registryHarness struct {
119+ server *httptest.Server
120+ ref string
121+}
122+
123+func newRegistryHarness(t *testing.T, maxBlobChunkSize int64) *registryHarness {
124+ t.Helper()
125+
126+ log := zerowrap.Default()
127+ rootDir := t.TempDir()
128+
129+ blobStore, err := filesystem.NewBlobStorage(rootDir, log)
130+ require.NoError(t, err)
131+
132+ manifestStore, err := filesystem.NewManifestStorage(rootDir, log)
133+ require.NoError(t, err)
134+
135+ svc := registrysvc.NewService(blobStore, manifestStore, noopEventPublisher{})
136+ handler := registryhttp.NewHandler(svc, log, maxBlobChunkSize)
137+ server := httptest.NewServer(handler)
138+
139+ host := strings.TrimPrefix(server.URL, "http://")
140+
141+ return &registryHarness{
142+ server: server,
143+ ref: host + "/testapp:v1.0.0",
144+ }
145+}
146+
147+func (h *registryHarness) Close() {
148+ h.server.Close()
149+}
150+
151+func (h *registryHarness) Push(ctx context.Context, img v1.Image, opts ...registrypush.Option) error {
152+ allOpts := append([]registrypush.Option{
153+ registrypush.WithTransport(h.server.Client().Transport),
154+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
155+ return img, nil
156+ }),
157+ }, opts...)
158+
159+ pusher := registrypush.New(allOpts...)
160+ return pusher.Push(ctx, h.ref)
161+}
162+
163+func (h *registryHarness) GetManifest(t *testing.T) *http.Response {
164+ t.Helper()
165+
166+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/v2/testapp/manifests/v1.0.0", h.server.URL), nil)
167+ require.NoError(t, err)
168+
169+ resp, err := h.server.Client().Do(req)
170+ require.NoError(t, err)
171+
172+ return resp
173+}
174+
175+func (h *registryHarness) RequireManifestAndBlobs(ctx context.Context, t *testing.T, img v1.Image) {
176+ t.Helper()
177+
178+ manifestResp := h.GetManifest(t)
179+ require.Equal(t, http.StatusOK, manifestResp.StatusCode)
180+ require.NoError(t, manifestResp.Body.Close())
181+
182+ layers, err := img.Layers()
183+ require.NoError(t, err)
184+
185+ for _, layer := range layers {
186+ digest, digestErr := layer.Digest()
187+ require.NoError(t, digestErr)
188+
189+ req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v2/testapp/blobs/%s", h.server.URL, digest.String()), nil)
190+ require.NoError(t, reqErr)
191+
192+ resp, respErr := h.server.Client().Do(req)
193+ require.NoError(t, respErr)
194+ assert.Equal(t, http.StatusOK, resp.StatusCode)
195+ require.NoError(t, resp.Body.Close())
196+ }
197+}
198+
199+type noopEventPublisher struct{}
200+
201+func (noopEventPublisher) Publish(domain.EventType, any) error {
202+ return nil
203+}
A pkg/registrypush/push.go +505 -0
@@ -0,0 +1,505 @@
1+package registrypush
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "net/url"
13+ "os"
14+ "strings"
15+
16+ "github.com/docker/docker/client"
17+ "github.com/google/go-containerregistry/pkg/authn"
18+ "github.com/google/go-containerregistry/pkg/name"
19+ v1 "github.com/google/go-containerregistry/pkg/v1"
20+ "github.com/google/go-containerregistry/pkg/v1/tarball"
21+)
22+
23+// DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit
24+// and Gordon's default max_blob_chunk_size of 95MB.
25+const DefaultChunkSize = 50 * 1024 * 1024
26+
27+// ImageSource provides a v1.Image for a given ref.
28+// The default reads from the local Docker/Podman daemon.
29+// Override with WithImageSource for testing.
30+type ImageSource func(ctx context.Context, ref string) (v1.Image, error)
31+
32+// Pusher uploads container images to a registry using chunked blob uploads.
33+type Pusher struct {
34+ chunkSize int64
35+ transport http.RoundTripper
36+ imageSource ImageSource
37+ progress io.Writer
38+}
39+
40+// Option configures a Pusher.
41+type Option func(*Pusher)
42+
43+// WithChunkSize sets the maximum size of each blob upload chunk.
44+func WithChunkSize(size int64) Option {
45+ return func(p *Pusher) { p.chunkSize = size }
46+}
47+
48+// WithTransport sets a custom HTTP transport (for testing).
49+func WithTransport(t http.RoundTripper) Option {
50+ return func(p *Pusher) { p.transport = t }
51+}
52+
53+// WithImageSource overrides the default daemon-based image reader.
54+func WithImageSource(src ImageSource) Option {
55+ return func(p *Pusher) { p.imageSource = src }
56+}
57+
58+// WithProgress sets a writer for progress messages.
59+func WithProgress(w io.Writer) Option {
60+ return func(p *Pusher) { p.progress = w }
61+}
62+
63+// New creates a Pusher with the given options.
64+func New(opts ...Option) *Pusher {
65+ p := &Pusher{chunkSize: DefaultChunkSize}
66+ for _, o := range opts {
67+ o(p)
68+ }
69+ if p.transport == nil {
70+ p.transport = http.DefaultTransport
71+ }
72+ if p.imageSource == nil {
73+ p.imageSource = defaultImageSource
74+ }
75+ return p
76+}
77+
78+// Push reads a local image (by ref) from the Docker/Podman daemon and uploads
79+// it to the registry encoded in the ref, using chunked blob uploads.
80+//
81+// The ref must be fully qualified: registry.example.com/name:tag
82+func (p *Pusher) Push(ctx context.Context, ref string) error {
83+ parsedRef, err := name.ParseReference(ref)
84+ if err != nil {
85+ return fmt.Errorf("failed to parse image ref %s: %w", ref, err)
86+ }
87+
88+ tag, ok := parsedRef.(name.Tag)
89+ if !ok {
90+ return fmt.Errorf("image ref %s must include a tag", ref)
91+ }
92+
93+ authHeader, err := resolveAuthHeader(parsedRef)
94+ if err != nil {
95+ return err
96+ }
97+
98+ img, err := p.imageSource(ctx, ref)
99+ if err != nil {
100+ return err
101+ }
102+
103+ baseURL := registryBaseURL(parsedRef.Context().RegistryStr())
104+ repo := parsedRef.Context().RepositoryStr()
105+
106+ if err := p.uploadImageLayers(ctx, img, baseURL, repo, authHeader); err != nil {
107+ return err
108+ }
109+
110+ if err := p.uploadImageConfig(ctx, img, baseURL, repo, authHeader); err != nil {
111+ return err
112+ }
113+
114+ return p.uploadManifest(ctx, baseURL, repo, tag.Identifier(), img, authHeader)
115+}
116+
117+// UploadBlob uploads a single blob to the registry using chunked uploads.
118+// If the blob already exists, it is skipped.
119+func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, size int64, content io.Reader, auth string) error {
120+ if err := p.checkBlobExists(ctx, baseURL, repo, digest, auth); err == nil {
121+ return nil
122+ } else if err != errBlobMissing {
123+ return err
124+ }
125+
126+ uploadURL, err := p.startBlobUpload(ctx, baseURL, repo, auth)
127+ if err != nil {
128+ return err
129+ }
130+
131+ chunks := chunkCount(size, p.chunkSize)
132+ if p.progress != nil {
133+ if _, err := fmt.Fprintf(p.progress, "Uploading blob %s (%d bytes, %d chunks)\n", digest, size, chunks); err != nil {
134+ return fmt.Errorf("failed to write upload progress: %w", err)
135+ }
136+ }
137+
138+ uploadURL, err = p.uploadBlobChunks(ctx, uploadURL, content, auth)
139+ if err != nil {
140+ return err
141+ }
142+
143+ return p.finalizeBlobUpload(ctx, uploadURL, digest, auth)
144+}
145+
146+// defaultImageSource reads an image from the local Docker/Podman daemon.
147+func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) {
148+ cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
149+ if err != nil {
150+ return nil, fmt.Errorf("failed to create Docker client: %w", err)
151+ }
152+ defer cli.Close()
153+
154+ tag, err := name.NewTag(ref)
155+ if err != nil {
156+ return nil, fmt.Errorf("failed to parse image ref %s: %w", ref, err)
157+ }
158+
159+ rc, err := cli.ImageSave(ctx, []string{ref})
160+ if err != nil {
161+ return nil, fmt.Errorf("failed to export image %s: %w", ref, err)
162+ }
163+
164+ tmpFile, err := os.CreateTemp("", "gordon-push-*.tar")
165+ if err != nil {
166+ if closeErr := rc.Close(); closeErr != nil {
167+ return nil, fmt.Errorf("failed to create temp file for image export: %w (also failed to close image export stream: %v)", err, closeErr)
168+ }
169+ return nil, fmt.Errorf("failed to create temp file for image export: %w", err)
170+ }
171+ tmpPath := tmpFile.Name()
172+
173+ if _, err := io.Copy(tmpFile, rc); err != nil {
174+ if closeErr := rc.Close(); closeErr != nil {
175+ _ = tmpFile.Close()
176+ _ = os.Remove(tmpPath)
177+ return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close image export stream: %v)", err, closeErr)
178+ }
179+ if closeErr := tmpFile.Close(); closeErr != nil {
180+ _ = os.Remove(tmpPath)
181+ return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close temp file: %v)", err, closeErr)
182+ }
183+ if removeErr := os.Remove(tmpPath); removeErr != nil {
184+ return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to remove temp file %s: %v)", err, tmpPath, removeErr)
185+ }
186+ return nil, fmt.Errorf("failed to write image tar to temp file: %w", err)
187+ }
188+ if err := rc.Close(); err != nil {
189+ _ = tmpFile.Close()
190+ _ = os.Remove(tmpPath)
191+ return nil, fmt.Errorf("failed to close image export stream: %w", err)
192+ }
193+ if err := tmpFile.Close(); err != nil {
194+ _ = os.Remove(tmpPath)
195+ return nil, fmt.Errorf("failed to close temp file %s: %w", tmpPath, err)
196+ }
197+
198+ img, err := tarball.ImageFromPath(tmpPath, &tag)
199+ if err != nil {
200+ _ = os.Remove(tmpPath)
201+ return nil, fmt.Errorf("failed to parse image tar for %s: %w", ref, err)
202+ }
203+ return img, nil
204+}
205+
206+func (p *Pusher) uploadImageLayers(ctx context.Context, img v1.Image, baseURL, repo, auth string) error {
207+ layers, err := img.Layers()
208+ if err != nil {
209+ return fmt.Errorf("failed to read image layers: %w", err)
210+ }
211+
212+ for _, layer := range layers {
213+ if err := p.uploadLayer(ctx, baseURL, repo, layer, auth); err != nil {
214+ return err
215+ }
216+ }
217+
218+ return nil
219+}
220+
221+func (p *Pusher) uploadLayer(ctx context.Context, baseURL, repo string, layer v1.Layer, auth string) error {
222+ digest, err := layer.Digest()
223+ if err != nil {
224+ return fmt.Errorf("failed to get layer digest: %w", err)
225+ }
226+
227+ size, err := layer.Size()
228+ if err != nil {
229+ return fmt.Errorf("failed to get layer size for %s: %w", digest, err)
230+ }
231+
232+ content, err := layer.Compressed()
233+ if err != nil {
234+ return fmt.Errorf("failed to read layer %s: %w", digest, err)
235+ }
236+ defer content.Close()
237+
238+ if err := p.UploadBlob(ctx, baseURL, repo, digest.String(), size, content, auth); err != nil {
239+ return fmt.Errorf("failed to upload layer %s: %w", digest, err)
240+ }
241+
242+ return nil
243+}
244+
245+func (p *Pusher) uploadImageConfig(ctx context.Context, img v1.Image, baseURL, repo, auth string) error {
246+ config, err := img.RawConfigFile()
247+ if err != nil {
248+ return fmt.Errorf("failed to read image config: %w", err)
249+ }
250+
251+ digest := fmt.Sprintf("sha256:%x", sha256.Sum256(config))
252+ if err := p.UploadBlob(ctx, baseURL, repo, digest, int64(len(config)), bytesReader(config), auth); err != nil {
253+ return fmt.Errorf("failed to upload image config %s: %w", digest, err)
254+ }
255+
256+ return nil
257+}
258+
259+func (p *Pusher) uploadManifest(ctx context.Context, baseURL, repo, tag string, img v1.Image, auth string) error {
260+ manifest, err := img.RawManifest()
261+ if err != nil {
262+ return fmt.Errorf("failed to read image manifest: %w", err)
263+ }
264+
265+ mediaType, err := img.MediaType()
266+ if err != nil {
267+ return fmt.Errorf("failed to read image media type: %w", err)
268+ }
269+
270+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, fmt.Sprintf("%s/v2/%s/manifests/%s", baseURL, repo, tag), bytesReader(manifest))
271+ if err != nil {
272+ return fmt.Errorf("failed to create manifest upload request: %w", err)
273+ }
274+ setAuthHeader(req, auth)
275+ req.Header.Set("Content-Type", string(mediaType))
276+ req.ContentLength = int64(len(manifest))
277+
278+ resp, err := p.httpClient().Do(req)
279+ if err != nil {
280+ return fmt.Errorf("failed to upload manifest: %w", err)
281+ }
282+ defer resp.Body.Close()
283+
284+ if resp.StatusCode != http.StatusCreated {
285+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
286+ return fmt.Errorf("manifest upload returned status %d: %s", resp.StatusCode, string(body))
287+ }
288+
289+ return nil
290+}
291+
292+func resolveAuthHeader(ref name.Reference) (string, error) {
293+ authenticator, err := authn.DefaultKeychain.Resolve(ref.Context().Registry)
294+ if err != nil {
295+ return "", fmt.Errorf("failed to resolve registry credentials for %s: %w", ref.Context().RegistryStr(), err)
296+ }
297+
298+ authConfig, err := authenticator.Authorization()
299+ if err != nil {
300+ return "", fmt.Errorf("failed to authorize for registry %s: %w", ref.Context().RegistryStr(), err)
301+ }
302+
303+ if authConfig == nil {
304+ return "", nil
305+ }
306+
307+ if authConfig.RegistryToken != "" {
308+ return "Bearer " + authConfig.RegistryToken, nil
309+ }
310+
311+ if authConfig.Auth != "" {
312+ return "Basic " + authConfig.Auth, nil
313+ }
314+
315+ if authConfig.Username != "" || authConfig.Password != "" {
316+ encoded := base64.StdEncoding.EncodeToString([]byte(authConfig.Username + ":" + authConfig.Password))
317+ return "Basic " + encoded, nil
318+ }
319+
320+ return "", nil
321+}
322+
323+func registryBaseURL(host string) string {
324+ if strings.HasPrefix(host, "localhost:") || strings.HasPrefix(host, "127.0.0.1:") || host == "localhost" || host == "127.0.0.1" {
325+ return "http://" + host
326+ }
327+ return "https://" + host
328+}
329+
330+var errBlobMissing = errors.New("blob missing")
331+
332+func (p *Pusher) checkBlobExists(ctx context.Context, baseURL, repo, digest, auth string) error {
333+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, fmt.Sprintf("%s/v2/%s/blobs/%s", baseURL, repo, digest), nil)
334+ if err != nil {
335+ return fmt.Errorf("failed to create blob HEAD request: %w", err)
336+ }
337+ setAuthHeader(req, auth)
338+
339+ resp, err := p.httpClient().Do(req)
340+ if err != nil {
341+ return fmt.Errorf("failed to check blob existence: %w", err)
342+ }
343+ defer resp.Body.Close()
344+
345+ switch resp.StatusCode {
346+ case http.StatusOK:
347+ return nil
348+ case http.StatusNotFound:
349+ return errBlobMissing
350+ default:
351+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
352+ return fmt.Errorf("blob existence check returned status %d: %s", resp.StatusCode, string(body))
353+ }
354+}
355+
356+func (p *Pusher) startBlobUpload(ctx context.Context, baseURL, repo, auth string) (string, error) {
357+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/v2/%s/blobs/uploads/", baseURL, repo), nil)
358+ if err != nil {
359+ return "", fmt.Errorf("failed to create blob upload start request: %w", err)
360+ }
361+ setAuthHeader(req, auth)
362+
363+ resp, err := p.httpClient().Do(req)
364+ if err != nil {
365+ return "", fmt.Errorf("failed to start blob upload: %w", err)
366+ }
367+ defer resp.Body.Close()
368+
369+ if resp.StatusCode != http.StatusAccepted {
370+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
371+ return "", fmt.Errorf("blob upload start returned status %d: %s", resp.StatusCode, string(body))
372+ }
373+
374+ location, err := resolveLocation(baseURL, resp.Header.Get("Location"))
375+ if err != nil {
376+ return "", fmt.Errorf("failed to resolve blob upload location: %w", err)
377+ }
378+
379+ return location, nil
380+}
381+
382+func (p *Pusher) uploadBlobChunks(ctx context.Context, uploadURL string, content io.Reader, auth string) (string, error) {
383+ buffer := make([]byte, p.chunkSize)
384+ offset := int64(0)
385+
386+ for {
387+ readBytes, err := io.ReadFull(io.LimitReader(content, p.chunkSize), buffer)
388+ if err == io.EOF {
389+ return uploadURL, nil
390+ }
391+ if err != nil && err != io.ErrUnexpectedEOF {
392+ return "", fmt.Errorf("failed to read blob chunk: %w", err)
393+ }
394+
395+ nextURL, patchErr := p.uploadBlobChunk(ctx, uploadURL, buffer[:readBytes], offset, auth)
396+ if patchErr != nil {
397+ return "", patchErr
398+ }
399+
400+ uploadURL = nextURL
401+ offset += int64(readBytes)
402+
403+ if err == io.ErrUnexpectedEOF {
404+ return uploadURL, nil
405+ }
406+ }
407+}
408+
409+func (p *Pusher) uploadBlobChunk(ctx context.Context, uploadURL string, chunk []byte, offset int64, auth string) (string, error) {
410+ req, err := http.NewRequestWithContext(ctx, http.MethodPatch, uploadURL, bytesReader(chunk))
411+ if err != nil {
412+ return "", fmt.Errorf("failed to create blob chunk request: %w", err)
413+ }
414+ setAuthHeader(req, auth)
415+ req.Header.Set("Content-Type", "application/octet-stream")
416+ req.Header.Set("Content-Range", fmt.Sprintf("%d-%d", offset, offset+int64(len(chunk))-1))
417+ req.ContentLength = int64(len(chunk))
418+
419+ resp, err := p.httpClient().Do(req)
420+ if err != nil {
421+ return "", fmt.Errorf("failed to upload blob chunk: %w", err)
422+ }
423+ defer resp.Body.Close()
424+
425+ if resp.StatusCode != http.StatusAccepted {
426+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
427+ return "", fmt.Errorf("blob chunk upload returned status %d: %s", resp.StatusCode, string(body))
428+ }
429+
430+ nextURL, err := resolveLocation(uploadURL, resp.Header.Get("Location"))
431+ if err != nil {
432+ return "", fmt.Errorf("failed to resolve next blob upload location: %w", err)
433+ }
434+
435+ return nextURL, nil
436+}
437+
438+func (p *Pusher) finalizeBlobUpload(ctx context.Context, uploadURL, digest, auth string) error {
439+ parsedURL, err := url.Parse(uploadURL)
440+ if err != nil {
441+ return fmt.Errorf("failed to parse blob upload location: %w", err)
442+ }
443+
444+ query := parsedURL.Query()
445+ query.Set("digest", digest)
446+ parsedURL.RawQuery = query.Encode()
447+
448+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, parsedURL.String(), nil)
449+ if err != nil {
450+ return fmt.Errorf("failed to create blob finalize request: %w", err)
451+ }
452+ setAuthHeader(req, auth)
453+
454+ resp, err := p.httpClient().Do(req)
455+ if err != nil {
456+ return fmt.Errorf("failed to finalize blob upload: %w", err)
457+ }
458+ defer resp.Body.Close()
459+
460+ if resp.StatusCode != http.StatusCreated {
461+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
462+ return fmt.Errorf("blob upload finalize returned status %d: %s", resp.StatusCode, string(body))
463+ }
464+
465+ return nil
466+}
467+
468+func (p *Pusher) httpClient() *http.Client {
469+ return &http.Client{Transport: p.transport}
470+}
471+
472+func setAuthHeader(req *http.Request, auth string) {
473+ if auth != "" {
474+ req.Header.Set("Authorization", auth)
475+ }
476+}
477+
478+func resolveLocation(baseURL, location string) (string, error) {
479+ if location == "" {
480+ return "", fmt.Errorf("missing Location header")
481+ }
482+
483+ base, err := url.Parse(baseURL)
484+ if err != nil {
485+ return "", err
486+ }
487+
488+ ref, err := url.Parse(location)
489+ if err != nil {
490+ return "", err
491+ }
492+
493+ return base.ResolveReference(ref).String(), nil
494+}
495+
496+func chunkCount(size, chunkSize int64) int64 {
497+ if size == 0 || chunkSize <= 0 {
498+ return 0
499+ }
500+ return (size + chunkSize - 1) / chunkSize
501+}
502+
503+func bytesReader(chunk []byte) io.Reader {
504+ return bytes.NewReader(chunk)
505+}
@@ -0,0 +1,505 @@
1+package registrypush
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "net/url"
13+ "os"
14+ "strings"
15+
16+ "github.com/docker/docker/client"
17+ "github.com/google/go-containerregistry/pkg/authn"
18+ "github.com/google/go-containerregistry/pkg/name"
19+ v1 "github.com/google/go-containerregistry/pkg/v1"
20+ "github.com/google/go-containerregistry/pkg/v1/tarball"
21+)
22+
23+// DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit
24+// and Gordon's default max_blob_chunk_size of 95MB.
25+const DefaultChunkSize = 50 * 1024 * 1024
26+
27+// ImageSource provides a v1.Image for a given ref.
28+// The default reads from the local Docker/Podman daemon.
29+// Override with WithImageSource for testing.
30+type ImageSource func(ctx context.Context, ref string) (v1.Image, error)
31+
32+// Pusher uploads container images to a registry using chunked blob uploads.
33+type Pusher struct {
34+ chunkSize int64
35+ transport http.RoundTripper
36+ imageSource ImageSource
37+ progress io.Writer
38+}
39+
40+// Option configures a Pusher.
41+type Option func(*Pusher)
42+
43+// WithChunkSize sets the maximum size of each blob upload chunk.
44+func WithChunkSize(size int64) Option {
45+ return func(p *Pusher) { p.chunkSize = size }
46+}
47+
48+// WithTransport sets a custom HTTP transport (for testing).
49+func WithTransport(t http.RoundTripper) Option {
50+ return func(p *Pusher) { p.transport = t }
51+}
52+
53+// WithImageSource overrides the default daemon-based image reader.
54+func WithImageSource(src ImageSource) Option {
55+ return func(p *Pusher) { p.imageSource = src }
56+}
57+
58+// WithProgress sets a writer for progress messages.
59+func WithProgress(w io.Writer) Option {
60+ return func(p *Pusher) { p.progress = w }
61+}
62+
63+// New creates a Pusher with the given options.
64+func New(opts ...Option) *Pusher {
65+ p := &Pusher{chunkSize: DefaultChunkSize}
66+ for _, o := range opts {
67+ o(p)
68+ }
69+ if p.transport == nil {
70+ p.transport = http.DefaultTransport
71+ }
72+ if p.imageSource == nil {
73+ p.imageSource = defaultImageSource
74+ }
75+ return p
76+}
77+
78+// Push reads a local image (by ref) from the Docker/Podman daemon and uploads
79+// it to the registry encoded in the ref, using chunked blob uploads.
80+//
81+// The ref must be fully qualified: registry.example.com/name:tag
82+func (p *Pusher) Push(ctx context.Context, ref string) error {
83+ parsedRef, err := name.ParseReference(ref)
84+ if err != nil {
85+ return fmt.Errorf("failed to parse image ref %s: %w", ref, err)
86+ }
87+
88+ tag, ok := parsedRef.(name.Tag)
89+ if !ok {
90+ return fmt.Errorf("image ref %s must include a tag", ref)
91+ }
92+
93+ authHeader, err := resolveAuthHeader(parsedRef)
94+ if err != nil {
95+ return err
96+ }
97+
98+ img, err := p.imageSource(ctx, ref)
99+ if err != nil {
100+ return err
101+ }
102+
103+ baseURL := registryBaseURL(parsedRef.Context().RegistryStr())
104+ repo := parsedRef.Context().RepositoryStr()
105+
106+ if err := p.uploadImageLayers(ctx, img, baseURL, repo, authHeader); err != nil {
107+ return err
108+ }
109+
110+ if err := p.uploadImageConfig(ctx, img, baseURL, repo, authHeader); err != nil {
111+ return err
112+ }
113+
114+ return p.uploadManifest(ctx, baseURL, repo, tag.Identifier(), img, authHeader)
115+}
116+
117+// UploadBlob uploads a single blob to the registry using chunked uploads.
118+// If the blob already exists, it is skipped.
119+func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, size int64, content io.Reader, auth string) error {
120+ if err := p.checkBlobExists(ctx, baseURL, repo, digest, auth); err == nil {
121+ return nil
122+ } else if err != errBlobMissing {
123+ return err
124+ }
125+
126+ uploadURL, err := p.startBlobUpload(ctx, baseURL, repo, auth)
127+ if err != nil {
128+ return err
129+ }
130+
131+ chunks := chunkCount(size, p.chunkSize)
132+ if p.progress != nil {
133+ if _, err := fmt.Fprintf(p.progress, "Uploading blob %s (%d bytes, %d chunks)\n", digest, size, chunks); err != nil {
134+ return fmt.Errorf("failed to write upload progress: %w", err)
135+ }
136+ }
137+
138+ uploadURL, err = p.uploadBlobChunks(ctx, uploadURL, content, auth)
139+ if err != nil {
140+ return err
141+ }
142+
143+ return p.finalizeBlobUpload(ctx, uploadURL, digest, auth)
144+}
145+
146+// defaultImageSource reads an image from the local Docker/Podman daemon.
147+func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) {
148+ cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
149+ if err != nil {
150+ return nil, fmt.Errorf("failed to create Docker client: %w", err)
151+ }
152+ defer cli.Close()
153+
154+ tag, err := name.NewTag(ref)
155+ if err != nil {
156+ return nil, fmt.Errorf("failed to parse image ref %s: %w", ref, err)
157+ }
158+
159+ rc, err := cli.ImageSave(ctx, []string{ref})
160+ if err != nil {
161+ return nil, fmt.Errorf("failed to export image %s: %w", ref, err)
162+ }
163+
164+ tmpFile, err := os.CreateTemp("", "gordon-push-*.tar")
165+ if err != nil {
166+ if closeErr := rc.Close(); closeErr != nil {
167+ return nil, fmt.Errorf("failed to create temp file for image export: %w (also failed to close image export stream: %v)", err, closeErr)
168+ }
169+ return nil, fmt.Errorf("failed to create temp file for image export: %w", err)
170+ }
171+ tmpPath := tmpFile.Name()
172+
173+ if _, err := io.Copy(tmpFile, rc); err != nil {
174+ if closeErr := rc.Close(); closeErr != nil {
175+ _ = tmpFile.Close()
176+ _ = os.Remove(tmpPath)
177+ return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close image export stream: %v)", err, closeErr)
178+ }
179+ if closeErr := tmpFile.Close(); closeErr != nil {
180+ _ = os.Remove(tmpPath)
181+ return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to close temp file: %v)", err, closeErr)
182+ }
183+ if removeErr := os.Remove(tmpPath); removeErr != nil {
184+ return nil, fmt.Errorf("failed to write image tar to temp file: %w (also failed to remove temp file %s: %v)", err, tmpPath, removeErr)
185+ }
186+ return nil, fmt.Errorf("failed to write image tar to temp file: %w", err)
187+ }
188+ if err := rc.Close(); err != nil {
189+ _ = tmpFile.Close()
190+ _ = os.Remove(tmpPath)
191+ return nil, fmt.Errorf("failed to close image export stream: %w", err)
192+ }
193+ if err := tmpFile.Close(); err != nil {
194+ _ = os.Remove(tmpPath)
195+ return nil, fmt.Errorf("failed to close temp file %s: %w", tmpPath, err)
196+ }
197+
198+ img, err := tarball.ImageFromPath(tmpPath, &tag)
199+ if err != nil {
200+ _ = os.Remove(tmpPath)
201+ return nil, fmt.Errorf("failed to parse image tar for %s: %w", ref, err)
202+ }
203+ return img, nil
204+}
205+
206+func (p *Pusher) uploadImageLayers(ctx context.Context, img v1.Image, baseURL, repo, auth string) error {
207+ layers, err := img.Layers()
208+ if err != nil {
209+ return fmt.Errorf("failed to read image layers: %w", err)
210+ }
211+
212+ for _, layer := range layers {
213+ if err := p.uploadLayer(ctx, baseURL, repo, layer, auth); err != nil {
214+ return err
215+ }
216+ }
217+
218+ return nil
219+}
220+
221+func (p *Pusher) uploadLayer(ctx context.Context, baseURL, repo string, layer v1.Layer, auth string) error {
222+ digest, err := layer.Digest()
223+ if err != nil {
224+ return fmt.Errorf("failed to get layer digest: %w", err)
225+ }
226+
227+ size, err := layer.Size()
228+ if err != nil {
229+ return fmt.Errorf("failed to get layer size for %s: %w", digest, err)
230+ }
231+
232+ content, err := layer.Compressed()
233+ if err != nil {
234+ return fmt.Errorf("failed to read layer %s: %w", digest, err)
235+ }
236+ defer content.Close()
237+
238+ if err := p.UploadBlob(ctx, baseURL, repo, digest.String(), size, content, auth); err != nil {
239+ return fmt.Errorf("failed to upload layer %s: %w", digest, err)
240+ }
241+
242+ return nil
243+}
244+
245+func (p *Pusher) uploadImageConfig(ctx context.Context, img v1.Image, baseURL, repo, auth string) error {
246+ config, err := img.RawConfigFile()
247+ if err != nil {
248+ return fmt.Errorf("failed to read image config: %w", err)
249+ }
250+
251+ digest := fmt.Sprintf("sha256:%x", sha256.Sum256(config))
252+ if err := p.UploadBlob(ctx, baseURL, repo, digest, int64(len(config)), bytesReader(config), auth); err != nil {
253+ return fmt.Errorf("failed to upload image config %s: %w", digest, err)
254+ }
255+
256+ return nil
257+}
258+
259+func (p *Pusher) uploadManifest(ctx context.Context, baseURL, repo, tag string, img v1.Image, auth string) error {
260+ manifest, err := img.RawManifest()
261+ if err != nil {
262+ return fmt.Errorf("failed to read image manifest: %w", err)
263+ }
264+
265+ mediaType, err := img.MediaType()
266+ if err != nil {
267+ return fmt.Errorf("failed to read image media type: %w", err)
268+ }
269+
270+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, fmt.Sprintf("%s/v2/%s/manifests/%s", baseURL, repo, tag), bytesReader(manifest))
271+ if err != nil {
272+ return fmt.Errorf("failed to create manifest upload request: %w", err)
273+ }
274+ setAuthHeader(req, auth)
275+ req.Header.Set("Content-Type", string(mediaType))
276+ req.ContentLength = int64(len(manifest))
277+
278+ resp, err := p.httpClient().Do(req)
279+ if err != nil {
280+ return fmt.Errorf("failed to upload manifest: %w", err)
281+ }
282+ defer resp.Body.Close()
283+
284+ if resp.StatusCode != http.StatusCreated {
285+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
286+ return fmt.Errorf("manifest upload returned status %d: %s", resp.StatusCode, string(body))
287+ }
288+
289+ return nil
290+}
291+
292+func resolveAuthHeader(ref name.Reference) (string, error) {
293+ authenticator, err := authn.DefaultKeychain.Resolve(ref.Context().Registry)
294+ if err != nil {
295+ return "", fmt.Errorf("failed to resolve registry credentials for %s: %w", ref.Context().RegistryStr(), err)
296+ }
297+
298+ authConfig, err := authenticator.Authorization()
299+ if err != nil {
300+ return "", fmt.Errorf("failed to authorize for registry %s: %w", ref.Context().RegistryStr(), err)
301+ }
302+
303+ if authConfig == nil {
304+ return "", nil
305+ }
306+
307+ if authConfig.RegistryToken != "" {
308+ return "Bearer " + authConfig.RegistryToken, nil
309+ }
310+
311+ if authConfig.Auth != "" {
312+ return "Basic " + authConfig.Auth, nil
313+ }
314+
315+ if authConfig.Username != "" || authConfig.Password != "" {
316+ encoded := base64.StdEncoding.EncodeToString([]byte(authConfig.Username + ":" + authConfig.Password))
317+ return "Basic " + encoded, nil
318+ }
319+
320+ return "", nil
321+}
322+
323+func registryBaseURL(host string) string {
324+ if strings.HasPrefix(host, "localhost:") || strings.HasPrefix(host, "127.0.0.1:") || host == "localhost" || host == "127.0.0.1" {
325+ return "http://" + host
326+ }
327+ return "https://" + host
328+}
329+
330+var errBlobMissing = errors.New("blob missing")
331+
332+func (p *Pusher) checkBlobExists(ctx context.Context, baseURL, repo, digest, auth string) error {
333+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, fmt.Sprintf("%s/v2/%s/blobs/%s", baseURL, repo, digest), nil)
334+ if err != nil {
335+ return fmt.Errorf("failed to create blob HEAD request: %w", err)
336+ }
337+ setAuthHeader(req, auth)
338+
339+ resp, err := p.httpClient().Do(req)
340+ if err != nil {
341+ return fmt.Errorf("failed to check blob existence: %w", err)
342+ }
343+ defer resp.Body.Close()
344+
345+ switch resp.StatusCode {
346+ case http.StatusOK:
347+ return nil
348+ case http.StatusNotFound:
349+ return errBlobMissing
350+ default:
351+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
352+ return fmt.Errorf("blob existence check returned status %d: %s", resp.StatusCode, string(body))
353+ }
354+}
355+
356+func (p *Pusher) startBlobUpload(ctx context.Context, baseURL, repo, auth string) (string, error) {
357+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/v2/%s/blobs/uploads/", baseURL, repo), nil)
358+ if err != nil {
359+ return "", fmt.Errorf("failed to create blob upload start request: %w", err)
360+ }
361+ setAuthHeader(req, auth)
362+
363+ resp, err := p.httpClient().Do(req)
364+ if err != nil {
365+ return "", fmt.Errorf("failed to start blob upload: %w", err)
366+ }
367+ defer resp.Body.Close()
368+
369+ if resp.StatusCode != http.StatusAccepted {
370+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
371+ return "", fmt.Errorf("blob upload start returned status %d: %s", resp.StatusCode, string(body))
372+ }
373+
374+ location, err := resolveLocation(baseURL, resp.Header.Get("Location"))
375+ if err != nil {
376+ return "", fmt.Errorf("failed to resolve blob upload location: %w", err)
377+ }
378+
379+ return location, nil
380+}
381+
382+func (p *Pusher) uploadBlobChunks(ctx context.Context, uploadURL string, content io.Reader, auth string) (string, error) {
383+ buffer := make([]byte, p.chunkSize)
384+ offset := int64(0)
385+
386+ for {
387+ readBytes, err := io.ReadFull(io.LimitReader(content, p.chunkSize), buffer)
388+ if err == io.EOF {
389+ return uploadURL, nil
390+ }
391+ if err != nil && err != io.ErrUnexpectedEOF {
392+ return "", fmt.Errorf("failed to read blob chunk: %w", err)
393+ }
394+
395+ nextURL, patchErr := p.uploadBlobChunk(ctx, uploadURL, buffer[:readBytes], offset, auth)
396+ if patchErr != nil {
397+ return "", patchErr
398+ }
399+
400+ uploadURL = nextURL
401+ offset += int64(readBytes)
402+
403+ if err == io.ErrUnexpectedEOF {
404+ return uploadURL, nil
405+ }
406+ }
407+}
408+
409+func (p *Pusher) uploadBlobChunk(ctx context.Context, uploadURL string, chunk []byte, offset int64, auth string) (string, error) {
410+ req, err := http.NewRequestWithContext(ctx, http.MethodPatch, uploadURL, bytesReader(chunk))
411+ if err != nil {
412+ return "", fmt.Errorf("failed to create blob chunk request: %w", err)
413+ }
414+ setAuthHeader(req, auth)
415+ req.Header.Set("Content-Type", "application/octet-stream")
416+ req.Header.Set("Content-Range", fmt.Sprintf("%d-%d", offset, offset+int64(len(chunk))-1))
417+ req.ContentLength = int64(len(chunk))
418+
419+ resp, err := p.httpClient().Do(req)
420+ if err != nil {
421+ return "", fmt.Errorf("failed to upload blob chunk: %w", err)
422+ }
423+ defer resp.Body.Close()
424+
425+ if resp.StatusCode != http.StatusAccepted {
426+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
427+ return "", fmt.Errorf("blob chunk upload returned status %d: %s", resp.StatusCode, string(body))
428+ }
429+
430+ nextURL, err := resolveLocation(uploadURL, resp.Header.Get("Location"))
431+ if err != nil {
432+ return "", fmt.Errorf("failed to resolve next blob upload location: %w", err)
433+ }
434+
435+ return nextURL, nil
436+}
437+
438+func (p *Pusher) finalizeBlobUpload(ctx context.Context, uploadURL, digest, auth string) error {
439+ parsedURL, err := url.Parse(uploadURL)
440+ if err != nil {
441+ return fmt.Errorf("failed to parse blob upload location: %w", err)
442+ }
443+
444+ query := parsedURL.Query()
445+ query.Set("digest", digest)
446+ parsedURL.RawQuery = query.Encode()
447+
448+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, parsedURL.String(), nil)
449+ if err != nil {
450+ return fmt.Errorf("failed to create blob finalize request: %w", err)
451+ }
452+ setAuthHeader(req, auth)
453+
454+ resp, err := p.httpClient().Do(req)
455+ if err != nil {
456+ return fmt.Errorf("failed to finalize blob upload: %w", err)
457+ }
458+ defer resp.Body.Close()
459+
460+ if resp.StatusCode != http.StatusCreated {
461+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
462+ return fmt.Errorf("blob upload finalize returned status %d: %s", resp.StatusCode, string(body))
463+ }
464+
465+ return nil
466+}
467+
468+func (p *Pusher) httpClient() *http.Client {
469+ return &http.Client{Transport: p.transport}
470+}
471+
472+func setAuthHeader(req *http.Request, auth string) {
473+ if auth != "" {
474+ req.Header.Set("Authorization", auth)
475+ }
476+}
477+
478+func resolveLocation(baseURL, location string) (string, error) {
479+ if location == "" {
480+ return "", fmt.Errorf("missing Location header")
481+ }
482+
483+ base, err := url.Parse(baseURL)
484+ if err != nil {
485+ return "", err
486+ }
487+
488+ ref, err := url.Parse(location)
489+ if err != nil {
490+ return "", err
491+ }
492+
493+ return base.ResolveReference(ref).String(), nil
494+}
495+
496+func chunkCount(size, chunkSize int64) int64 {
497+ if size == 0 || chunkSize <= 0 {
498+ return 0
499+ }
500+ return (size + chunkSize - 1) / chunkSize
501+}
502+
503+func bytesReader(chunk []byte) io.Reader {
504+ return bytes.NewReader(chunk)
505+}
A pkg/registrypush/push_test.go +488 -0
@@ -0,0 +1,488 @@
1+package registrypush_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/url"
12+ "strconv"
13+ "strings"
14+ "testing"
15+
16+ "github.com/google/go-containerregistry/pkg/v1"
17+ "github.com/google/go-containerregistry/pkg/v1/random"
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "github.com/bnema/gordon/pkg/registrypush"
22+)
23+
24+func TestNew_defaults(t *testing.T) {
25+ p := registrypush.New()
26+ assert.NotNil(t, p)
27+}
28+
29+func TestNew_withChunkSize(t *testing.T) {
30+ p := registrypush.New(registrypush.WithChunkSize(10 * 1024 * 1024))
31+ assert.NotNil(t, p)
32+}
33+
34+func TestPusher_UploadBlob(t *testing.T) {
35+ testCases := []struct {
36+ name string
37+ blobSize int
38+ chunkSize int64
39+ headStatus int
40+ expectedPatchSizes []int
41+ expectedHeadCalls int
42+ expectedPostCalls int
43+ expectedPutCalls int
44+ }{
45+ {
46+ name: "small blob under chunk size",
47+ blobSize: 100,
48+ chunkSize: 1024,
49+ headStatus: http.StatusNotFound,
50+ expectedPatchSizes: []int{100},
51+ expectedHeadCalls: 1,
52+ expectedPostCalls: 1,
53+ expectedPutCalls: 1,
54+ },
55+ {
56+ name: "exact chunk size",
57+ blobSize: 1024,
58+ chunkSize: 1024,
59+ headStatus: http.StatusNotFound,
60+ expectedPatchSizes: []int{1024},
61+ expectedHeadCalls: 1,
62+ expectedPostCalls: 1,
63+ expectedPutCalls: 1,
64+ },
65+ {
66+ name: "large blob across multiple chunks",
67+ blobSize: 3072,
68+ chunkSize: 1024,
69+ headStatus: http.StatusNotFound,
70+ expectedPatchSizes: []int{1024, 1024, 1024},
71+ expectedHeadCalls: 1,
72+ expectedPostCalls: 1,
73+ expectedPutCalls: 1,
74+ },
75+ {
76+ name: "blob already exists",
77+ blobSize: 512,
78+ chunkSize: 1024,
79+ headStatus: http.StatusOK,
80+ expectedPatchSizes: nil,
81+ expectedHeadCalls: 1,
82+ expectedPostCalls: 0,
83+ expectedPutCalls: 0,
84+ },
85+ {
86+ name: "auth header present on every request",
87+ blobSize: 1536,
88+ chunkSize: 1024,
89+ headStatus: http.StatusNotFound,
90+ expectedPatchSizes: []int{1024, 512},
91+ expectedHeadCalls: 1,
92+ expectedPostCalls: 1,
93+ expectedPutCalls: 1,
94+ },
95+ }
96+
97+ for _, tc := range testCases {
98+ t.Run(tc.name, func(t *testing.T) {
99+ const authHeader = "Bearer test-token"
100+ const repo = "demo/app"
101+ const digest = "sha256:testdigest"
102+
103+ blob := bytes.Repeat([]byte("a"), tc.blobSize)
104+ serverState := newFakeBlobUploadRegistry(t, authHeader, digest, tc.headStatus)
105+ server := httptest.NewServer(serverState.handler())
106+ defer server.Close()
107+
108+ p := registrypush.New(
109+ registrypush.WithChunkSize(tc.chunkSize),
110+ registrypush.WithTransport(server.Client().Transport),
111+ )
112+
113+ err := p.UploadBlob(context.Background(), server.URL, repo, digest, int64(len(blob)), bytes.NewReader(blob), authHeader)
114+ require.NoError(t, err)
115+
116+ assert.Equal(t, tc.expectedHeadCalls, serverState.headCalls)
117+ assert.Equal(t, tc.expectedPostCalls, serverState.postCalls)
118+ assert.Equal(t, tc.expectedPutCalls, serverState.putCalls)
119+ assert.Equal(t, tc.expectedPatchSizes, serverState.patchSizes)
120+ assert.Equal(t, tc.expectedPatchSizes, serverState.rangeSizes)
121+ assert.Equal(t, serverState.requestCount, serverState.authCount)
122+ })
123+ }
124+}
125+
126+func TestPusher_Push(t *testing.T) {
127+ testCases := []struct {
128+ name string
129+ imageSize int64
130+ layerCount int64
131+ existingBlobDigests func(t *testing.T, img v1.Image) map[string]bool
132+ expectedUploadedBlobCount int
133+ expectedManifestMediaTypes []string
134+ }{
135+ {
136+ name: "single-layer image uploads config layer and manifest",
137+ imageSize: 1024,
138+ layerCount: 1,
139+ existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} },
140+ expectedUploadedBlobCount: 2,
141+ expectedManifestMediaTypes: []string{
142+ "application/vnd.docker.distribution.manifest.v2+json",
143+ typesOCIManifestSchema1(),
144+ },
145+ },
146+ {
147+ name: "multi-layer image uploads all blobs and manifest",
148+ imageSize: 1024,
149+ layerCount: 2,
150+ existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} },
151+ expectedUploadedBlobCount: 3,
152+ expectedManifestMediaTypes: []string{
153+ "application/vnd.docker.distribution.manifest.v2+json",
154+ typesOCIManifestSchema1(),
155+ },
156+ },
157+ {
158+ name: "existing layer is skipped while others upload",
159+ imageSize: 1024,
160+ layerCount: 2,
161+ existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool {
162+ t.Helper()
163+ layers, err := img.Layers()
164+ require.NoError(t, err)
165+ digest, err := layers[0].Digest()
166+ require.NoError(t, err)
167+ return map[string]bool{digest.String(): true}
168+ },
169+ expectedUploadedBlobCount: 2,
170+ expectedManifestMediaTypes: []string{
171+ "application/vnd.docker.distribution.manifest.v2+json",
172+ typesOCIManifestSchema1(),
173+ },
174+ },
175+ }
176+
177+ for _, tc := range testCases {
178+ t.Run(tc.name, func(t *testing.T) {
179+ img, err := random.Image(tc.imageSize, tc.layerCount)
180+ require.NoError(t, err)
181+
182+ serverState := newFakePushRegistry(t, img, tc.existingBlobDigests(t, img))
183+ server := httptest.NewServer(serverState.handler())
184+ defer server.Close()
185+
186+ serverURL, err := url.Parse(server.URL)
187+ require.NoError(t, err)
188+ ref := serverURL.Host + "/demo/app:v1"
189+
190+ p := registrypush.New(
191+ registrypush.WithTransport(server.Client().Transport),
192+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
193+ return img, nil
194+ }),
195+ )
196+
197+ err = p.Push(context.Background(), ref)
198+ require.NoError(t, err)
199+
200+ expectedDigests := make([]string, 0, len(serverState.expectedBlobs))
201+ for digest := range serverState.expectedBlobs {
202+ expectedDigests = append(expectedDigests, digest)
203+ assert.Contains(t, serverState.headChecks, digest)
204+ }
205+ assert.Len(t, serverState.headChecks, len(expectedDigests))
206+
207+ assert.Len(t, serverState.uploadedBlobs, tc.expectedUploadedBlobCount)
208+ for digest, content := range serverState.uploadedBlobs {
209+ assert.Equal(t, serverState.expectedBlobs[digest], content)
210+ }
211+
212+ manifestPut, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
213+ require.True(t, ok)
214+ assert.Contains(t, tc.expectedManifestMediaTypes, manifestPut.contentType)
215+ assert.Equal(t, serverState.expectedManifest, manifestPut.body)
216+ })
217+ }
218+}
219+
220+type fakeBlobUploadRegistry struct {
221+ t *testing.T
222+ expectedAuth string
223+ expectedDigest string
224+ headStatus int
225+ patchSizes []int
226+ rangeSizes []int
227+ headCalls int
228+ postCalls int
229+ putCalls int
230+ requestCount int
231+ authCount int
232+ nextOffset int
233+ location string
234+ uuid string
235+}
236+
237+func newFakeBlobUploadRegistry(t *testing.T, expectedAuth, expectedDigest string, headStatus int) *fakeBlobUploadRegistry {
238+ t.Helper()
239+ return &fakeBlobUploadRegistry{
240+ t: t,
241+ expectedAuth: expectedAuth,
242+ expectedDigest: expectedDigest,
243+ headStatus: headStatus,
244+ location: "/upload/session-1",
245+ uuid: "session-1",
246+ }
247+}
248+
249+func (f *fakeBlobUploadRegistry) handler() http.Handler {
250+ f.t.Helper()
251+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
252+ f.requestCount++
253+ if r.Header.Get("Authorization") == f.expectedAuth {
254+ f.authCount++
255+ }
256+
257+ switch {
258+ case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
259+ f.headCalls++
260+ w.WriteHeader(f.headStatus)
261+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"):
262+ f.postCalls++
263+ w.Header().Set("Location", f.location)
264+ w.Header().Set("Docker-Upload-UUID", f.uuid)
265+ w.Header().Set("Range", rangeHeaderValue(0, f.nextOffset-1))
266+ w.WriteHeader(http.StatusAccepted)
267+ case r.Method == http.MethodPatch && r.URL.Path == f.location:
268+ f.handlePatch(w, r)
269+ case r.Method == http.MethodPut && r.URL.Path == f.location:
270+ f.putCalls++
271+ assert.Equal(f.t, f.expectedDigest, r.URL.Query().Get("digest"))
272+ body, err := io.ReadAll(r.Body)
273+ require.NoError(f.t, err)
274+ assert.Len(f.t, body, 0)
275+ w.Header().Set("Location", f.location)
276+ w.Header().Set("Docker-Content-Digest", f.expectedDigest)
277+ w.WriteHeader(http.StatusCreated)
278+ default:
279+ f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
280+ }
281+ })
282+}
283+
284+func (f *fakeBlobUploadRegistry) handlePatch(w http.ResponseWriter, r *http.Request) {
285+ body, err := io.ReadAll(r.Body)
286+ require.NoError(f.t, err)
287+
288+ start, end := parseContentRange(f.t, r.Header.Get("Content-Range"))
289+ assert.Equal(f.t, f.nextOffset, start)
290+ assert.Equal(f.t, len(body), end-start+1)
291+
292+ contentLength, err := strconv.Atoi(r.Header.Get("Content-Length"))
293+ require.NoError(f.t, err)
294+ assert.Equal(f.t, len(body), contentLength)
295+ assert.Equal(f.t, "application/octet-stream", r.Header.Get("Content-Type"))
296+
297+ f.patchSizes = append(f.patchSizes, len(body))
298+ f.rangeSizes = append(f.rangeSizes, end-start+1)
299+ f.nextOffset = end + 1
300+
301+ w.Header().Set("Location", f.location)
302+ w.Header().Set("Docker-Upload-UUID", f.uuid)
303+ w.Header().Set("Range", rangeHeaderValue(0, end))
304+ w.WriteHeader(http.StatusAccepted)
305+}
306+
307+func parseContentRange(t *testing.T, value string) (int, int) {
308+ t.Helper()
309+ parts := strings.Split(value, "-")
310+ require.Len(t, parts, 2)
311+
312+ start, err := strconv.Atoi(parts[0])
313+ require.NoError(t, err)
314+
315+ end, err := strconv.Atoi(parts[1])
316+ require.NoError(t, err)
317+
318+ return start, end
319+}
320+
321+func rangeHeaderValue(start, end int) string {
322+ if end < start {
323+ return ""
324+ }
325+ return fmt.Sprintf("%d-%d", start, end)
326+}
327+
328+type fakePushRegistry struct {
329+ t *testing.T
330+ existingBlobs map[string]bool
331+ uploadedBlobs map[string][]byte
332+ manifestPuts map[string]fakeManifestPut
333+ uploads map[string]*fakeUploadSession
334+ nextUploadID int
335+ headChecks []string
336+ expectedBlobs map[string][]byte
337+ expectedRepo string
338+ expectedTag string
339+ expectedMT string
340+ expectedManifest []byte
341+}
342+
343+type fakeUploadSession struct {
344+ repo string
345+ data []byte
346+}
347+
348+type fakeManifestPut struct {
349+ contentType string
350+ body []byte
351+}
352+
353+func newFakePushRegistry(t *testing.T, img v1.Image, existingBlobs map[string]bool) *fakePushRegistry {
354+ t.Helper()
355+
356+ layers, err := img.Layers()
357+ require.NoError(t, err)
358+
359+ expectedBlobs := make(map[string][]byte, len(layers)+1)
360+ for _, layer := range layers {
361+ digest, err := layer.Digest()
362+ require.NoError(t, err)
363+ rc, err := layer.Compressed()
364+ require.NoError(t, err)
365+ content, err := io.ReadAll(rc)
366+ require.NoError(t, err)
367+ require.NoError(t, rc.Close())
368+ expectedBlobs[digest.String()] = content
369+ }
370+
371+ config, err := img.RawConfigFile()
372+ require.NoError(t, err)
373+ configDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(config))
374+ expectedBlobs[configDigest] = config
375+
376+ manifest, err := img.RawManifest()
377+ require.NoError(t, err)
378+ mediaType, err := img.MediaType()
379+ require.NoError(t, err)
380+
381+ return &fakePushRegistry{
382+ t: t,
383+ existingBlobs: existingBlobs,
384+ uploadedBlobs: make(map[string][]byte),
385+ manifestPuts: make(map[string]fakeManifestPut),
386+ uploads: make(map[string]*fakeUploadSession),
387+ expectedBlobs: expectedBlobs,
388+ expectedRepo: "demo/app",
389+ expectedTag: "v1",
390+ expectedMT: string(mediaType),
391+ expectedManifest: manifest,
392+ }
393+}
394+
395+func (f *fakePushRegistry) handler() http.Handler {
396+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
397+ switch {
398+ case r.Method == http.MethodHead && strings.HasPrefix(r.URL.Path, "/v2/") && strings.Contains(r.URL.Path, "/blobs/"):
399+ f.handleBlobHead(w, r)
400+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"):
401+ f.handleBlobStart(w, r)
402+ case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/blobs/uploads/"):
403+ f.handleBlobPatch(w, r)
404+ case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/blobs/uploads/"):
405+ f.handleBlobFinalize(w, r)
406+ case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/manifests/"):
407+ f.handleManifestPut(w, r)
408+ default:
409+ f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
410+ }
411+ })
412+}
413+
414+func (f *fakePushRegistry) handleBlobHead(w http.ResponseWriter, r *http.Request) {
415+ digest := pathTail(r.URL.Path)
416+ f.headChecks = append(f.headChecks, digest)
417+ if f.existingBlobs[digest] {
418+ w.WriteHeader(http.StatusOK)
419+ return
420+ }
421+ w.WriteHeader(http.StatusNotFound)
422+}
423+
424+func (f *fakePushRegistry) handleBlobStart(w http.ResponseWriter, r *http.Request) {
425+ repo := pathSegmentBetween(r.URL.Path, "/v2/", "/blobs/uploads/")
426+ assert.Equal(f.t, f.expectedRepo, repo)
427+ f.nextUploadID++
428+ uploadPath := fmt.Sprintf("/v2/%s/blobs/uploads/upload-%d", repo, f.nextUploadID)
429+ f.uploads[uploadPath] = &fakeUploadSession{repo: repo}
430+ w.Header().Set("Location", uploadPath)
431+ w.WriteHeader(http.StatusAccepted)
432+}
433+
434+func (f *fakePushRegistry) handleBlobPatch(w http.ResponseWriter, r *http.Request) {
435+ session := f.uploads[r.URL.Path]
436+ if session == nil {
437+ f.t.Fatalf("missing upload session for %s", r.URL.Path)
438+ }
439+ body, err := io.ReadAll(r.Body)
440+ require.NoError(f.t, err)
441+ session.data = append(session.data, body...)
442+ w.Header().Set("Location", r.URL.Path)
443+ w.WriteHeader(http.StatusAccepted)
444+}
445+
446+func (f *fakePushRegistry) handleBlobFinalize(w http.ResponseWriter, r *http.Request) {
447+ session := f.uploads[r.URL.Path]
448+ if session == nil {
449+ f.t.Fatalf("missing upload session for %s", r.URL.Path)
450+ }
451+ digest := r.URL.Query().Get("digest")
452+ f.uploadedBlobs[digest] = append([]byte(nil), session.data...)
453+ delete(f.uploads, r.URL.Path)
454+ w.WriteHeader(http.StatusCreated)
455+}
456+
457+func (f *fakePushRegistry) handleManifestPut(w http.ResponseWriter, r *http.Request) {
458+ repo := pathSegmentBetween(r.URL.Path, "/v2/", "/manifests/")
459+ tag := pathTail(r.URL.Path)
460+ assert.Equal(f.t, f.expectedRepo, repo)
461+ assert.Equal(f.t, f.expectedTag, tag)
462+ body, err := io.ReadAll(r.Body)
463+ require.NoError(f.t, err)
464+ f.manifestPuts[repo+":"+tag] = fakeManifestPut{contentType: r.Header.Get("Content-Type"), body: body}
465+ w.WriteHeader(http.StatusCreated)
466+}
467+
468+func pathTail(path string) string {
469+ parts := strings.Split(path, "/")
470+ return parts[len(parts)-1]
471+}
472+
473+func pathSegmentBetween(value, start, end string) string {
474+ startIdx := strings.Index(value, start)
475+ if startIdx == -1 {
476+ return ""
477+ }
478+ trimmed := value[startIdx+len(start):]
479+ endIdx := strings.Index(trimmed, end)
480+ if endIdx == -1 {
481+ return ""
482+ }
483+ return trimmed[:endIdx]
484+}
485+
486+func typesOCIManifestSchema1() string {
487+ return "application/vnd.oci.image.manifest.v1+json"
488+}
@@ -0,0 +1,488 @@
1+package registrypush_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/url"
12+ "strconv"
13+ "strings"
14+ "testing"
15+
16+ "github.com/google/go-containerregistry/pkg/v1"
17+ "github.com/google/go-containerregistry/pkg/v1/random"
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "github.com/bnema/gordon/pkg/registrypush"
22+)
23+
24+func TestNew_defaults(t *testing.T) {
25+ p := registrypush.New()
26+ assert.NotNil(t, p)
27+}
28+
29+func TestNew_withChunkSize(t *testing.T) {
30+ p := registrypush.New(registrypush.WithChunkSize(10 * 1024 * 1024))
31+ assert.NotNil(t, p)
32+}
33+
34+func TestPusher_UploadBlob(t *testing.T) {
35+ testCases := []struct {
36+ name string
37+ blobSize int
38+ chunkSize int64
39+ headStatus int
40+ expectedPatchSizes []int
41+ expectedHeadCalls int
42+ expectedPostCalls int
43+ expectedPutCalls int
44+ }{
45+ {
46+ name: "small blob under chunk size",
47+ blobSize: 100,
48+ chunkSize: 1024,
49+ headStatus: http.StatusNotFound,
50+ expectedPatchSizes: []int{100},
51+ expectedHeadCalls: 1,
52+ expectedPostCalls: 1,
53+ expectedPutCalls: 1,
54+ },
55+ {
56+ name: "exact chunk size",
57+ blobSize: 1024,
58+ chunkSize: 1024,
59+ headStatus: http.StatusNotFound,
60+ expectedPatchSizes: []int{1024},
61+ expectedHeadCalls: 1,
62+ expectedPostCalls: 1,
63+ expectedPutCalls: 1,
64+ },
65+ {
66+ name: "large blob across multiple chunks",
67+ blobSize: 3072,
68+ chunkSize: 1024,
69+ headStatus: http.StatusNotFound,
70+ expectedPatchSizes: []int{1024, 1024, 1024},
71+ expectedHeadCalls: 1,
72+ expectedPostCalls: 1,
73+ expectedPutCalls: 1,
74+ },
75+ {
76+ name: "blob already exists",
77+ blobSize: 512,
78+ chunkSize: 1024,
79+ headStatus: http.StatusOK,
80+ expectedPatchSizes: nil,
81+ expectedHeadCalls: 1,
82+ expectedPostCalls: 0,
83+ expectedPutCalls: 0,
84+ },
85+ {
86+ name: "auth header present on every request",
87+ blobSize: 1536,
88+ chunkSize: 1024,
89+ headStatus: http.StatusNotFound,
90+ expectedPatchSizes: []int{1024, 512},
91+ expectedHeadCalls: 1,
92+ expectedPostCalls: 1,
93+ expectedPutCalls: 1,
94+ },
95+ }
96+
97+ for _, tc := range testCases {
98+ t.Run(tc.name, func(t *testing.T) {
99+ const authHeader = "Bearer test-token"
100+ const repo = "demo/app"
101+ const digest = "sha256:testdigest"
102+
103+ blob := bytes.Repeat([]byte("a"), tc.blobSize)
104+ serverState := newFakeBlobUploadRegistry(t, authHeader, digest, tc.headStatus)
105+ server := httptest.NewServer(serverState.handler())
106+ defer server.Close()
107+
108+ p := registrypush.New(
109+ registrypush.WithChunkSize(tc.chunkSize),
110+ registrypush.WithTransport(server.Client().Transport),
111+ )
112+
113+ err := p.UploadBlob(context.Background(), server.URL, repo, digest, int64(len(blob)), bytes.NewReader(blob), authHeader)
114+ require.NoError(t, err)
115+
116+ assert.Equal(t, tc.expectedHeadCalls, serverState.headCalls)
117+ assert.Equal(t, tc.expectedPostCalls, serverState.postCalls)
118+ assert.Equal(t, tc.expectedPutCalls, serverState.putCalls)
119+ assert.Equal(t, tc.expectedPatchSizes, serverState.patchSizes)
120+ assert.Equal(t, tc.expectedPatchSizes, serverState.rangeSizes)
121+ assert.Equal(t, serverState.requestCount, serverState.authCount)
122+ })
123+ }
124+}
125+
126+func TestPusher_Push(t *testing.T) {
127+ testCases := []struct {
128+ name string
129+ imageSize int64
130+ layerCount int64
131+ existingBlobDigests func(t *testing.T, img v1.Image) map[string]bool
132+ expectedUploadedBlobCount int
133+ expectedManifestMediaTypes []string
134+ }{
135+ {
136+ name: "single-layer image uploads config layer and manifest",
137+ imageSize: 1024,
138+ layerCount: 1,
139+ existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} },
140+ expectedUploadedBlobCount: 2,
141+ expectedManifestMediaTypes: []string{
142+ "application/vnd.docker.distribution.manifest.v2+json",
143+ typesOCIManifestSchema1(),
144+ },
145+ },
146+ {
147+ name: "multi-layer image uploads all blobs and manifest",
148+ imageSize: 1024,
149+ layerCount: 2,
150+ existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} },
151+ expectedUploadedBlobCount: 3,
152+ expectedManifestMediaTypes: []string{
153+ "application/vnd.docker.distribution.manifest.v2+json",
154+ typesOCIManifestSchema1(),
155+ },
156+ },
157+ {
158+ name: "existing layer is skipped while others upload",
159+ imageSize: 1024,
160+ layerCount: 2,
161+ existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool {
162+ t.Helper()
163+ layers, err := img.Layers()
164+ require.NoError(t, err)
165+ digest, err := layers[0].Digest()
166+ require.NoError(t, err)
167+ return map[string]bool{digest.String(): true}
168+ },
169+ expectedUploadedBlobCount: 2,
170+ expectedManifestMediaTypes: []string{
171+ "application/vnd.docker.distribution.manifest.v2+json",
172+ typesOCIManifestSchema1(),
173+ },
174+ },
175+ }
176+
177+ for _, tc := range testCases {
178+ t.Run(tc.name, func(t *testing.T) {
179+ img, err := random.Image(tc.imageSize, tc.layerCount)
180+ require.NoError(t, err)
181+
182+ serverState := newFakePushRegistry(t, img, tc.existingBlobDigests(t, img))
183+ server := httptest.NewServer(serverState.handler())
184+ defer server.Close()
185+
186+ serverURL, err := url.Parse(server.URL)
187+ require.NoError(t, err)
188+ ref := serverURL.Host + "/demo/app:v1"
189+
190+ p := registrypush.New(
191+ registrypush.WithTransport(server.Client().Transport),
192+ registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
193+ return img, nil
194+ }),
195+ )
196+
197+ err = p.Push(context.Background(), ref)
198+ require.NoError(t, err)
199+
200+ expectedDigests := make([]string, 0, len(serverState.expectedBlobs))
201+ for digest := range serverState.expectedBlobs {
202+ expectedDigests = append(expectedDigests, digest)
203+ assert.Contains(t, serverState.headChecks, digest)
204+ }
205+ assert.Len(t, serverState.headChecks, len(expectedDigests))
206+
207+ assert.Len(t, serverState.uploadedBlobs, tc.expectedUploadedBlobCount)
208+ for digest, content := range serverState.uploadedBlobs {
209+ assert.Equal(t, serverState.expectedBlobs[digest], content)
210+ }
211+
212+ manifestPut, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
213+ require.True(t, ok)
214+ assert.Contains(t, tc.expectedManifestMediaTypes, manifestPut.contentType)
215+ assert.Equal(t, serverState.expectedManifest, manifestPut.body)
216+ })
217+ }
218+}
219+
220+type fakeBlobUploadRegistry struct {
221+ t *testing.T
222+ expectedAuth string
223+ expectedDigest string
224+ headStatus int
225+ patchSizes []int
226+ rangeSizes []int
227+ headCalls int
228+ postCalls int
229+ putCalls int
230+ requestCount int
231+ authCount int
232+ nextOffset int
233+ location string
234+ uuid string
235+}
236+
237+func newFakeBlobUploadRegistry(t *testing.T, expectedAuth, expectedDigest string, headStatus int) *fakeBlobUploadRegistry {
238+ t.Helper()
239+ return &fakeBlobUploadRegistry{
240+ t: t,
241+ expectedAuth: expectedAuth,
242+ expectedDigest: expectedDigest,
243+ headStatus: headStatus,
244+ location: "/upload/session-1",
245+ uuid: "session-1",
246+ }
247+}
248+
249+func (f *fakeBlobUploadRegistry) handler() http.Handler {
250+ f.t.Helper()
251+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
252+ f.requestCount++
253+ if r.Header.Get("Authorization") == f.expectedAuth {
254+ f.authCount++
255+ }
256+
257+ switch {
258+ case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
259+ f.headCalls++
260+ w.WriteHeader(f.headStatus)
261+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"):
262+ f.postCalls++
263+ w.Header().Set("Location", f.location)
264+ w.Header().Set("Docker-Upload-UUID", f.uuid)
265+ w.Header().Set("Range", rangeHeaderValue(0, f.nextOffset-1))
266+ w.WriteHeader(http.StatusAccepted)
267+ case r.Method == http.MethodPatch && r.URL.Path == f.location:
268+ f.handlePatch(w, r)
269+ case r.Method == http.MethodPut && r.URL.Path == f.location:
270+ f.putCalls++
271+ assert.Equal(f.t, f.expectedDigest, r.URL.Query().Get("digest"))
272+ body, err := io.ReadAll(r.Body)
273+ require.NoError(f.t, err)
274+ assert.Len(f.t, body, 0)
275+ w.Header().Set("Location", f.location)
276+ w.Header().Set("Docker-Content-Digest", f.expectedDigest)
277+ w.WriteHeader(http.StatusCreated)
278+ default:
279+ f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
280+ }
281+ })
282+}
283+
284+func (f *fakeBlobUploadRegistry) handlePatch(w http.ResponseWriter, r *http.Request) {
285+ body, err := io.ReadAll(r.Body)
286+ require.NoError(f.t, err)
287+
288+ start, end := parseContentRange(f.t, r.Header.Get("Content-Range"))
289+ assert.Equal(f.t, f.nextOffset, start)
290+ assert.Equal(f.t, len(body), end-start+1)
291+
292+ contentLength, err := strconv.Atoi(r.Header.Get("Content-Length"))
293+ require.NoError(f.t, err)
294+ assert.Equal(f.t, len(body), contentLength)
295+ assert.Equal(f.t, "application/octet-stream", r.Header.Get("Content-Type"))
296+
297+ f.patchSizes = append(f.patchSizes, len(body))
298+ f.rangeSizes = append(f.rangeSizes, end-start+1)
299+ f.nextOffset = end + 1
300+
301+ w.Header().Set("Location", f.location)
302+ w.Header().Set("Docker-Upload-UUID", f.uuid)
303+ w.Header().Set("Range", rangeHeaderValue(0, end))
304+ w.WriteHeader(http.StatusAccepted)
305+}
306+
307+func parseContentRange(t *testing.T, value string) (int, int) {
308+ t.Helper()
309+ parts := strings.Split(value, "-")
310+ require.Len(t, parts, 2)
311+
312+ start, err := strconv.Atoi(parts[0])
313+ require.NoError(t, err)
314+
315+ end, err := strconv.Atoi(parts[1])
316+ require.NoError(t, err)
317+
318+ return start, end
319+}
320+
321+func rangeHeaderValue(start, end int) string {
322+ if end < start {
323+ return ""
324+ }
325+ return fmt.Sprintf("%d-%d", start, end)
326+}
327+
328+type fakePushRegistry struct {
329+ t *testing.T
330+ existingBlobs map[string]bool
331+ uploadedBlobs map[string][]byte
332+ manifestPuts map[string]fakeManifestPut
333+ uploads map[string]*fakeUploadSession
334+ nextUploadID int
335+ headChecks []string
336+ expectedBlobs map[string][]byte
337+ expectedRepo string
338+ expectedTag string
339+ expectedMT string
340+ expectedManifest []byte
341+}
342+
343+type fakeUploadSession struct {
344+ repo string
345+ data []byte
346+}
347+
348+type fakeManifestPut struct {
349+ contentType string
350+ body []byte
351+}
352+
353+func newFakePushRegistry(t *testing.T, img v1.Image, existingBlobs map[string]bool) *fakePushRegistry {
354+ t.Helper()
355+
356+ layers, err := img.Layers()
357+ require.NoError(t, err)
358+
359+ expectedBlobs := make(map[string][]byte, len(layers)+1)
360+ for _, layer := range layers {
361+ digest, err := layer.Digest()
362+ require.NoError(t, err)
363+ rc, err := layer.Compressed()
364+ require.NoError(t, err)
365+ content, err := io.ReadAll(rc)
366+ require.NoError(t, err)
367+ require.NoError(t, rc.Close())
368+ expectedBlobs[digest.String()] = content
369+ }
370+
371+ config, err := img.RawConfigFile()
372+ require.NoError(t, err)
373+ configDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(config))
374+ expectedBlobs[configDigest] = config
375+
376+ manifest, err := img.RawManifest()
377+ require.NoError(t, err)
378+ mediaType, err := img.MediaType()
379+ require.NoError(t, err)
380+
381+ return &fakePushRegistry{
382+ t: t,
383+ existingBlobs: existingBlobs,
384+ uploadedBlobs: make(map[string][]byte),
385+ manifestPuts: make(map[string]fakeManifestPut),
386+ uploads: make(map[string]*fakeUploadSession),
387+ expectedBlobs: expectedBlobs,
388+ expectedRepo: "demo/app",
389+ expectedTag: "v1",
390+ expectedMT: string(mediaType),
391+ expectedManifest: manifest,
392+ }
393+}
394+
395+func (f *fakePushRegistry) handler() http.Handler {
396+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
397+ switch {
398+ case r.Method == http.MethodHead && strings.HasPrefix(r.URL.Path, "/v2/") && strings.Contains(r.URL.Path, "/blobs/"):
399+ f.handleBlobHead(w, r)
400+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"):
401+ f.handleBlobStart(w, r)
402+ case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/blobs/uploads/"):
403+ f.handleBlobPatch(w, r)
404+ case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/blobs/uploads/"):
405+ f.handleBlobFinalize(w, r)
406+ case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/manifests/"):
407+ f.handleManifestPut(w, r)
408+ default:
409+ f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
410+ }
411+ })
412+}
413+
414+func (f *fakePushRegistry) handleBlobHead(w http.ResponseWriter, r *http.Request) {
415+ digest := pathTail(r.URL.Path)
416+ f.headChecks = append(f.headChecks, digest)
417+ if f.existingBlobs[digest] {
418+ w.WriteHeader(http.StatusOK)
419+ return
420+ }
421+ w.WriteHeader(http.StatusNotFound)
422+}
423+
424+func (f *fakePushRegistry) handleBlobStart(w http.ResponseWriter, r *http.Request) {
425+ repo := pathSegmentBetween(r.URL.Path, "/v2/", "/blobs/uploads/")
426+ assert.Equal(f.t, f.expectedRepo, repo)
427+ f.nextUploadID++
428+ uploadPath := fmt.Sprintf("/v2/%s/blobs/uploads/upload-%d", repo, f.nextUploadID)
429+ f.uploads[uploadPath] = &fakeUploadSession{repo: repo}
430+ w.Header().Set("Location", uploadPath)
431+ w.WriteHeader(http.StatusAccepted)
432+}
433+
434+func (f *fakePushRegistry) handleBlobPatch(w http.ResponseWriter, r *http.Request) {
435+ session := f.uploads[r.URL.Path]
436+ if session == nil {
437+ f.t.Fatalf("missing upload session for %s", r.URL.Path)
438+ }
439+ body, err := io.ReadAll(r.Body)
440+ require.NoError(f.t, err)
441+ session.data = append(session.data, body...)
442+ w.Header().Set("Location", r.URL.Path)
443+ w.WriteHeader(http.StatusAccepted)
444+}
445+
446+func (f *fakePushRegistry) handleBlobFinalize(w http.ResponseWriter, r *http.Request) {
447+ session := f.uploads[r.URL.Path]
448+ if session == nil {
449+ f.t.Fatalf("missing upload session for %s", r.URL.Path)
450+ }
451+ digest := r.URL.Query().Get("digest")
452+ f.uploadedBlobs[digest] = append([]byte(nil), session.data...)
453+ delete(f.uploads, r.URL.Path)
454+ w.WriteHeader(http.StatusCreated)
455+}
456+
457+func (f *fakePushRegistry) handleManifestPut(w http.ResponseWriter, r *http.Request) {
458+ repo := pathSegmentBetween(r.URL.Path, "/v2/", "/manifests/")
459+ tag := pathTail(r.URL.Path)
460+ assert.Equal(f.t, f.expectedRepo, repo)
461+ assert.Equal(f.t, f.expectedTag, tag)
462+ body, err := io.ReadAll(r.Body)
463+ require.NoError(f.t, err)
464+ f.manifestPuts[repo+":"+tag] = fakeManifestPut{contentType: r.Header.Get("Content-Type"), body: body}
465+ w.WriteHeader(http.StatusCreated)
466+}
467+
468+func pathTail(path string) string {
469+ parts := strings.Split(path, "/")
470+ return parts[len(parts)-1]
471+}
472+
473+func pathSegmentBetween(value, start, end string) string {
474+ startIdx := strings.Index(value, start)
475+ if startIdx == -1 {
476+ return ""
477+ }
478+ trimmed := value[startIdx+len(start):]
479+ endIdx := strings.Index(trimmed, end)
480+ if endIdx == -1 {
481+ return ""
482+ }
483+ return trimmed[:endIdx]
484+}
485+
486+func typesOCIManifestSchema1() string {
487+ return "application/vnd.oci.image.manifest.v1+json"
488+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close