Merge pull request #106 from bnema/feat/native-registry-push
feat: native chunked registry push (Cloudflare-safe)
10 files · +1400 -29
M docs/cli/attachments.md +5 -0
| @@ -159,6 +159,11 @@ gordon attachments push <image> [options] | |||
| 159 | 159 | ||
| 160 | 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 | 167 | ### Examples | |
| 163 | 168 | ||
| 164 | 169 | ```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 | ### Examples | 167 | ### Examples | ||
| 163 | 168 | ||||
| 164 | ```bash | 169 | ```bash | ||
M docs/cli/push.md +5 -0
| @@ -72,6 +72,11 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile | |||
| 72 | 72 | - `gordon push` still requires the route to already exist so it can resolve the target image. | |
| 73 | 73 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | |
| 74 | 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 | 81 | ## Related | |
| 77 | 82 | ||
| @@ -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 | ## Related | 81 | ## Related | ||
| 77 | 82 | ||||
M docs/config/server.md +3 -2
| @@ -220,8 +220,9 @@ max_blob_chunk_size = "1GB" # Allow larger layers | |||
| 220 | 220 | ||
| 221 | 221 | **Behavior:** | |
| 222 | 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 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 | |
| 225 | 226 | ||
| 226 | 227 | ## Registry IP Allowlist | |
| 227 | 228 | ||
| @@ -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 images | 223 | + | - 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 directly | 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 | |||
| 225 | 226 | ||||
| 226 | ## Registry IP Allowlist | 227 | ## Registry IP Allowlist | ||
| 227 | 228 | ||||
M go.mod +11 -4
| @@ -10,6 +10,7 @@ require ( | |||
| 10 | 10 | github.com/docker/docker v28.5.2+incompatible | |
| 11 | 11 | github.com/docker/go-connections v0.5.0 | |
| 12 | 12 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 13 | + | github.com/google/go-containerregistry v0.21.2 | |
| 13 | 14 | github.com/google/uuid v1.6.0 | |
| 14 | 15 | github.com/mattn/go-runewidth v0.0.21 | |
| 15 | 16 | github.com/rivo/uniseg v0.4.7 | |
| @@ -39,20 +40,29 @@ require ( | |||
| 39 | 40 | github.com/clipperhouse/displaywidth v0.9.0 // indirect | |
| 40 | 41 | github.com/clipperhouse/stringish v0.1.1 // indirect | |
| 41 | 42 | 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 | 46 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 43 | 47 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect | |
| 48 | + | github.com/klauspost/compress v1.18.4 // indirect | |
| 44 | 49 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | |
| 45 | 50 | 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 | 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 47 | 54 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 48 | 55 | github.com/muesli/termenv v0.16.0 // indirect | |
| 49 | 56 | 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 | 59 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | |
| 51 | 60 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect | |
| 52 | 61 | go.opentelemetry.io/otel/log v0.18.0 // indirect | |
| 53 | 62 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | |
| 54 | 63 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 55 | 64 | golang.org/x/net v0.49.0 // indirect | |
| 65 | + | golang.org/x/sync v0.19.0 // indirect | |
| 56 | 66 | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect | |
| 57 | 67 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect | |
| 58 | 68 | google.golang.org/grpc v1.78.0 // indirect | |
| @@ -68,10 +78,9 @@ require ( | |||
| 68 | 78 | ) | |
| 69 | 79 | ||
| 70 | 80 | require ( | |
| 71 | - | github.com/Microsoft/go-winio v0.4.14 // indirect | |
| 81 | + | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 72 | 82 | github.com/containerd/errdefs v1.0.0 | |
| 73 | 83 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 74 | - | github.com/containerd/log v0.1.0 // indirect | |
| 75 | 84 | github.com/distribution/reference v0.6.0 // indirect | |
| 76 | 85 | github.com/docker/go-units v0.5.0 // indirect | |
| 77 | 86 | github.com/felixge/httpsnoop v1.0.4 // indirect | |
| @@ -83,9 +92,7 @@ require ( | |||
| 83 | 92 | github.com/mattn/go-colorable v0.1.14 // indirect | |
| 84 | 93 | github.com/mattn/go-isatty v0.0.20 // indirect | |
| 85 | 94 | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 86 | - | github.com/moby/sys/atomicwriter v0.1.0 // indirect | |
| 87 | 95 | github.com/moby/term v0.5.2 // indirect | |
| 88 | - | github.com/morikuni/aec v1.0.0 // indirect | |
| 89 | 96 | github.com/opencontainers/go-digest v1.0.0 // indirect | |
| 90 | 97 | github.com/opencontainers/image-spec v1.1.1 // indirect | |
| 91 | 98 | github.com/pelletier/go-toml/v2 v2.2.4 | |
| @@ -10,6 +10,7 @@ require ( | |||||
| 10 | github.com/docker/docker v28.5.2+incompatible | 10 | github.com/docker/docker v28.5.2+incompatible | ||
| 11 | github.com/docker/go-connections v0.5.0 | 11 | github.com/docker/go-connections v0.5.0 | ||
| 12 | github.com/golang-jwt/jwt/v5 v5.3.1 | 12 | 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.0 | 14 | github.com/google/uuid v1.6.0 | ||
| 14 | github.com/mattn/go-runewidth v0.0.21 | 15 | github.com/mattn/go-runewidth v0.0.21 | ||
| 15 | github.com/rivo/uniseg v0.4.7 | 16 | github.com/rivo/uniseg v0.4.7 | ||
| @@ -39,20 +40,29 @@ require ( | |||||
| 39 | github.com/clipperhouse/displaywidth v0.9.0 // indirect | 40 | github.com/clipperhouse/displaywidth v0.9.0 // indirect | ||
| 40 | github.com/clipperhouse/stringish v0.1.1 // indirect | 41 | github.com/clipperhouse/stringish v0.1.1 // indirect | ||
| 41 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | 42 | 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 // indirect | 46 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 43 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect | 47 | 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 // indirect | 49 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | ||
| 45 | github.com/mattn/go-localereader v0.0.1 // indirect | 50 | 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 // indirect | 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||
| 47 | github.com/muesli/cancelreader v0.2.2 // indirect | 54 | github.com/muesli/cancelreader v0.2.2 // indirect | ||
| 48 | github.com/muesli/termenv v0.16.0 // indirect | 55 | github.com/muesli/termenv v0.16.0 // indirect | ||
| 49 | github.com/rs/zerolog v1.34.0 // indirect | 56 | 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 // indirect | 59 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | ||
| 51 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect | 60 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect | ||
| 52 | go.opentelemetry.io/otel/log v0.18.0 // indirect | 61 | go.opentelemetry.io/otel/log v0.18.0 // indirect | ||
| 53 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | 62 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | ||
| 54 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 63 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 55 | golang.org/x/net v0.49.0 // indirect | 64 | 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 // indirect | 66 | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect | ||
| 57 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect | 67 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect | ||
| 58 | google.golang.org/grpc v1.78.0 // indirect | 68 | 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 // indirect | 81 | + | github.com/Microsoft/go-winio v0.6.2 // indirect |
| 72 | github.com/containerd/errdefs v1.0.0 | 82 | github.com/containerd/errdefs v1.0.0 | ||
| 73 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | 83 | 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 // indirect | 84 | github.com/distribution/reference v0.6.0 // indirect | ||
| 76 | github.com/docker/go-units v0.5.0 // indirect | 85 | github.com/docker/go-units v0.5.0 // indirect | ||
| 77 | github.com/felixge/httpsnoop v1.0.4 // indirect | 86 | github.com/felixge/httpsnoop v1.0.4 // indirect | ||
| @@ -83,9 +92,7 @@ require ( | |||||
| 83 | github.com/mattn/go-colorable v0.1.14 // indirect | 92 | github.com/mattn/go-colorable v0.1.14 // indirect | ||
| 84 | github.com/mattn/go-isatty v0.0.20 // indirect | 93 | github.com/mattn/go-isatty v0.0.20 // indirect | ||
| 85 | github.com/moby/docker-image-spec v1.3.1 // indirect | 94 | 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 // indirect | 95 | 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 // indirect | 96 | github.com/opencontainers/go-digest v1.0.0 // indirect | ||
| 90 | github.com/opencontainers/image-spec v1.1.1 // indirect | 97 | github.com/opencontainers/image-spec v1.1.1 // indirect | ||
| 91 | github.com/pelletier/go-toml/v2 v2.2.4 | 98 | 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 ( | |||
| 18 | 18 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 19 | 19 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 20 | 20 | "github.com/bnema/gordon/internal/domain" | |
| 21 | + | "github.com/bnema/gordon/pkg/registrypush" | |
| 21 | 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 | 31 | func newPushCmd() *cobra.Command { | |
| 25 | 32 | var ( | |
| 26 | 33 | noDeploy bool | |
| @@ -473,9 +480,8 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui | |||
| 473 | 480 | } | |
| 474 | 481 | ||
| 475 | 482 | // 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. | |
| 479 | 485 | fmt.Println("\nBuilding image...") | |
| 480 | 486 | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204 | |
| 481 | 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 | 494 | fmt.Println("Pushing...") | |
| 489 | - | if err := dockerPush(ctx, latestRef); err != nil { | |
| 495 | + | if err := dockerPushFn(ctx, latestRef); err != nil { | |
| 490 | 496 | return fmt.Errorf("failed to push %s: %w", latestRef, err) | |
| 491 | 497 | } | |
| 492 | 498 | if version != "latest" { | |
| 493 | - | if err := dockerPush(ctx, versionRef); err != nil { | |
| 499 | + | if err := dockerPushFn(ctx, versionRef); err != nil { | |
| 494 | 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 | 558 | localImage := fmt.Sprintf("%s/%s", registry, imageName) | |
| 553 | 559 | ||
| 554 | 560 | fmt.Println("\nChecking local image...") | |
| 555 | - | if !dockerImageExists(ctx, localImage) { | |
| 561 | + | if !dockerImageExistsFn(ctx, localImage) { | |
| 556 | 562 | return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage) | |
| 557 | 563 | } | |
| 558 | 564 | ||
| 559 | 565 | fmt.Println("Tagging...") | |
| 560 | - | if err := dockerTag(ctx, localImage, versionRef); err != nil { | |
| 566 | + | if err := dockerTagFn(ctx, localImage, versionRef); err != nil { | |
| 561 | 567 | return fmt.Errorf("failed to tag %s: %w", versionRef, err) | |
| 562 | 568 | } | |
| 563 | 569 | if version != "latest" { | |
| 564 | - | if err := dockerTag(ctx, localImage, latestRef); err != nil { | |
| 570 | + | if err := dockerTagFn(ctx, localImage, latestRef); err != nil { | |
| 565 | 571 | return fmt.Errorf("failed to tag %s: %w", latestRef, err) | |
| 566 | 572 | } | |
| 567 | 573 | } | |
| 568 | 574 | ||
| 569 | 575 | fmt.Println("Pushing...") | |
| 570 | - | if err := dockerPush(ctx, versionRef); err != nil { | |
| 576 | + | if err := dockerPushFn(ctx, versionRef); err != nil { | |
| 571 | 577 | return fmt.Errorf("failed to push %s: %w", versionRef, err) | |
| 572 | 578 | } | |
| 573 | 579 | if version != "latest" { | |
| 574 | - | if err := dockerPush(ctx, latestRef); err != nil { | |
| 580 | + | if err := dockerPushFn(ctx, latestRef); err != nil { | |
| 575 | 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 | 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 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) | |
| 751 | 757 | } | |
| 752 | 758 | ||
| 753 | 759 | 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 bool | 33 | 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 exceed | 483 | + | // The native registry push client handles chunked uploads to stay |
| 477 | - | // Cloudflare's 100MB per-request limit. Loading locally then | 484 | + | // 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 G204 | 486 | 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 parser | 753 | + | pusher := registrypush.New( |
| 748 | - | cmd.Stdout = os.Stdout | 754 | + | registrypush.WithProgress(os.Stderr), |
| 749 | - | cmd.Stderr = os.Stderr | 755 | + | ) |
| 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 ®istryHarness{ | |
| 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 ®istryHarness{ | |||
| 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 | + | } | |||