detached e02f654

fix(release): prepare v2.31 release blockers (#235)

* fix(release): prepare v2.31 release blockers

* fix(docker): add volume listing context
commit
e02f6546f2612bb46eb3b987defdf24b6e53cd7f
author
bnema · · 2026-08-23 14:44 UTC
committer
GitHub · · 2026-08-23 14:44 UTC
parent
fdf5cec
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0
34 files · +580 -263
M .github/actions/setup-gordon/action.yml +1 -1
@@ -83,7 +83,7 @@ runs:
8383 curl -fsSL -o "${dest}" "${url}"
8484
8585 # Verify checksum before extraction
86- archive_escaped="$(printf '%s\n' "${archive}" | sed 's/[.[\\*^$()+?{|]/\\\\&/g')"
86+ archive_escaped="$(printf '%s\n' "${archive}" | sed 's/[.[\*^$()+?{|]/\\&/g')"
8787 checksum_lines="$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\\*?${archive_escaped}$" "${checksums}" || true)"
8888 if [ "$(printf '%s\n' "${checksum_lines}" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then
8989 echo "Expected exactly one checksum for ${archive} in checksums.txt" >&2
@@ -83,7 +83,7 @@ runs:
83 curl -fsSL -o "${dest}" "${url}"83 curl -fsSL -o "${dest}" "${url}"
84 84
85 # Verify checksum before extraction85 # Verify checksum before extraction
86- archive_escaped="$(printf '%s\n' "${archive}" | sed 's/[.[\\*^$()+?{|]/\\\\&/g')"86+ archive_escaped="$(printf '%s\n' "${archive}" | sed 's/[.[\*^$()+?{|]/\\&/g')"
87 checksum_lines="$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\\*?${archive_escaped}$" "${checksums}" || true)"87 checksum_lines="$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\\*?${archive_escaped}$" "${checksums}" || true)"
88 if [ "$(printf '%s\n' "${checksum_lines}" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then88 if [ "$(printf '%s\n' "${checksum_lines}" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then
89 echo "Expected exactly one checksum for ${archive} in checksums.txt" >&289 echo "Expected exactly one checksum for ${archive} in checksums.txt" >&2
M .github/workflows/ci.yml +2 -2
@@ -37,7 +37,7 @@ jobs:
3737
3838 - uses: actions/setup-go@v5
3939 with:
40- go-version: stable
40+ go-version: "1.27.x"
4141
4242 - name: Cache Go modules
4343 uses: actions/cache@v5
@@ -62,7 +62,7 @@ jobs:
6262
6363 - uses: actions/setup-go@v5
6464 with:
65- go-version: stable
65+ go-version: "1.27.x"
6666
6767 - name: Cache Go modules
6868 uses: actions/cache@v5
@@ -37,7 +37,7 @@ jobs:
37 37
38 - uses: actions/setup-go@v538 - uses: actions/setup-go@v5
39 with:39 with:
40- go-version: stable40+ go-version: "1.27.x"
41 41
42 - name: Cache Go modules42 - name: Cache Go modules
43 uses: actions/cache@v543 uses: actions/cache@v5
@@ -62,7 +62,7 @@ jobs:
62 62
63 - uses: actions/setup-go@v563 - uses: actions/setup-go@v5
64 with:64 with:
65- go-version: stable65+ go-version: "1.27.x"
66 66
67 - name: Cache Go modules67 - name: Cache Go modules
68 uses: actions/cache@v568 uses: actions/cache@v5
M .github/workflows/release.yml +1 -1
@@ -20,7 +20,7 @@ jobs:
2020
2121 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
2222 with:
23- go-version: stable
23+ go-version: "1.27.x"
2424
2525 - name: Run GoReleaser
2626 uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
@@ -20,7 +20,7 @@ jobs:
20 20
21 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v521 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
22 with:22 with:
23- go-version: stable23+ go-version: "1.27.x"
24 24
25 - name: Run GoReleaser25 - name: Run GoReleaser
26 uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v626 uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
M Dockerfile +29 -33
@@ -1,34 +1,39 @@
1-# Gordon v2 - Production Dockerfile
2-# Multi-stage build for optimized container image
1+# Gordon v2 production image.
2+# Build with BuildKit or Podman to select TARGETOS and TARGETARCH.
33
4-# Build stage
5-FROM golang:1.26.1-alpine3.22 AS builder
4+ARG BUILDPLATFORM
5+ARG VERSION=dev
6+ARG COMMIT=unknown
7+ARG BUILD_DATE=unknown
8+
9+FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder
10+
11+ARG TARGETOS=linux
12+ARG TARGETARCH=amd64
13+ARG VERSION
14+ARG COMMIT
15+ARG BUILD_DATE
616
7-# Install build dependencies
817 RUN apk add --no-cache git ca-certificates tzdata
918
10-# Set working directory
1119 WORKDIR /app
1220
13-# Copy go mod files
1421 COPY go.mod go.sum ./
15-
16-# Download dependencies
1722 RUN go mod download
1823
19-# Copy source code
2024 COPY . .
2125
22-# Build the binary
23-RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
24- -ldflags='-w -s -extldflags "-static"' \
25- -a -installsuffix cgo \
26- -o gordon .
26+RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \
27+ -trimpath \
28+ -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" \
29+ -o /gordon ./main.go
2730
28-# Runtime stage
2931 FROM alpine:3.22
3032
31-# Install runtime dependencies
33+ARG VERSION
34+ARG COMMIT
35+ARG BUILD_DATE
36+
3237 RUN apk add --no-cache \
3338 ca-certificates \
3439 docker-cli \
@@ -37,35 +42,26 @@ RUN apk add --no-cache \
3742 tzdata \
3843 && rm -rf /var/cache/apk/*
3944
40-# Create non-root user
4145 RUN adduser -D -s /bin/sh gordon
4246
43-# Set working directory
4447 WORKDIR /app
4548
46-# Copy binary from builder
47-COPY --from=builder /app/gordon .
49+COPY --from=builder /gordon ./gordon
4850
49-# Create data directory
5051 RUN mkdir -p /data && chown gordon:gordon /data
51-
52-# Copy default configuration (optional)
5352 COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example
5453
55-# Switch to non-root user
5654 USER gordon
5755
58-# Expose ports
5956 EXPOSE 8088 5000
6057
6158 # Admin health is served on the registry/admin listener and is gated by auth in
6259 # normal deployments, so this image does not declare a Docker healthcheck.
63-
64-# Default command
6560 CMD ["./gordon", "serve"]
6661
67-# Metadata
68-LABEL maintainer="bnemam"
69-LABEL version="2.0"
70-LABEL description="Event-driven container deployment platform"
71-LABEL org.opencontainers.image.source="https://github.com/bnema/gordon"⏎̸
62+LABEL org.opencontainers.image.title="Gordon"
63+LABEL org.opencontainers.image.description="Event-driven container deployment platform"
64+LABEL org.opencontainers.image.source="https://github.com/bnema/gordon"
65+LABEL org.opencontainers.image.version="${VERSION}"
66+LABEL org.opencontainers.image.revision="${COMMIT}"
67+LABEL org.opencontainers.image.created="${BUILD_DATE}"
@@ -1,34 +1,39 @@
1-# Gordon v2 - Production Dockerfile1+# Gordon v2 production image.
2-# Multi-stage build for optimized container image2+# Build with BuildKit or Podman to select TARGETOS and TARGETARCH.
3 3
4-# Build stage4+ARG BUILDPLATFORM
5-FROM golang:1.26.1-alpine3.22 AS builder5+ARG VERSION=dev
6+ARG COMMIT=unknown
7+ARG BUILD_DATE=unknown
8+
9+FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder
10+
11+ARG TARGETOS=linux
12+ARG TARGETARCH=amd64
13+ARG VERSION
14+ARG COMMIT
15+ARG BUILD_DATE
6 16
7-# Install build dependencies
8 RUN apk add --no-cache git ca-certificates tzdata17 RUN apk add --no-cache git ca-certificates tzdata
9 18
10-# Set working directory
11 WORKDIR /app19 WORKDIR /app
12 20
13-# Copy go mod files
14 COPY go.mod go.sum ./21 COPY go.mod go.sum ./
15-
16-# Download dependencies
17 RUN go mod download22 RUN go mod download
18 23
19-# Copy source code
20 COPY . .24 COPY . .
21 25
22-# Build the binary26+RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \
23-RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \27+ -trimpath \
24- -ldflags='-w -s -extldflags "-static"' \28+ -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" \
25- -a -installsuffix cgo \29+ -o /gordon ./main.go
26- -o gordon .
27 30
28-# Runtime stage
29 FROM alpine:3.2231 FROM alpine:3.22
30 32
31-# Install runtime dependencies33+ARG VERSION
34+ARG COMMIT
35+ARG BUILD_DATE
36+
32 RUN apk add --no-cache \37 RUN apk add --no-cache \
33 ca-certificates \38 ca-certificates \
34 docker-cli \39 docker-cli \
@@ -37,35 +42,26 @@ RUN apk add --no-cache \
37 tzdata \42 tzdata \
38 && rm -rf /var/cache/apk/*43 && rm -rf /var/cache/apk/*
39 44
40-# Create non-root user
41 RUN adduser -D -s /bin/sh gordon45 RUN adduser -D -s /bin/sh gordon
42 46
43-# Set working directory
44 WORKDIR /app47 WORKDIR /app
45 48
46-# Copy binary from builder49+COPY --from=builder /gordon ./gordon
47-COPY --from=builder /app/gordon .
48 50
49-# Create data directory
50 RUN mkdir -p /data && chown gordon:gordon /data51 RUN mkdir -p /data && chown gordon:gordon /data
51-
52-# Copy default configuration (optional)
53 COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example52 COPY --chown=gordon:gordon gordon.toml.example /app/gordon.toml.example
54 53
55-# Switch to non-root user
56 USER gordon54 USER gordon
57 55
58-# Expose ports
59 EXPOSE 8088 500056 EXPOSE 8088 5000
60 57
61 # Admin health is served on the registry/admin listener and is gated by auth in58 # Admin health is served on the registry/admin listener and is gated by auth in
62 # normal deployments, so this image does not declare a Docker healthcheck.59 # normal deployments, so this image does not declare a Docker healthcheck.
63-
64-# Default command
65 CMD ["./gordon", "serve"]60 CMD ["./gordon", "serve"]
66 61
67-# Metadata62+LABEL org.opencontainers.image.title="Gordon"
68-LABEL maintainer="bnemam"63+LABEL org.opencontainers.image.description="Event-driven container deployment platform"
69-LABEL version="2.0"64+LABEL org.opencontainers.image.source="https://github.com/bnema/gordon"
70-LABEL description="Event-driven container deployment platform"65+LABEL org.opencontainers.image.version="${VERSION}"
71-LABEL org.opencontainers.image.source="https://github.com/bnema/gordon"⏎̸66+LABEL org.opencontainers.image.revision="${COMMIT}"
67+LABEL org.opencontainers.image.created="${BUILD_DATE}"
M Makefile +6 -4
@@ -95,15 +95,17 @@ build-local: ## Build binary for current platform
9595
9696 ##@ Release
9797
98-build-push: build ## Build and push Docker images
98+build-push: ## Build and push Docker images
9999 @echo "Cleaning up dangling images..."
100100 @$(ENGINE) image prune -f
101101
102102 @echo "Building and pushing Docker images..."
103103 @for arch in $(ARCHS); do \
104- cp $(DIST_DIR)/gordon-linux-$$arch gordon; \
105- $(ENGINE) build -t $(REPO):$(TAG)-$$arch .; \
106- rm gordon; \
104+ $(ENGINE) build --platform linux/$$arch \
105+ --build-arg VERSION="$(VERSION)" \
106+ --build-arg COMMIT="$(COMMIT)" \
107+ --build-arg BUILD_DATE="$(BUILD_DATE)" \
108+ -t $(REPO):$(TAG)-$$arch .; \
107109 $(ENGINE) push $(REPO):$(TAG)-$$arch; \
108110 done
109111
@@ -95,15 +95,17 @@ build-local: ## Build binary for current platform
95 95
96 ##@ Release96 ##@ Release
97 97
98-build-push: build ## Build and push Docker images98+build-push: ## Build and push Docker images
99 @echo "Cleaning up dangling images..."99 @echo "Cleaning up dangling images..."
100 @$(ENGINE) image prune -f100 @$(ENGINE) image prune -f
101 101
102 @echo "Building and pushing Docker images..."102 @echo "Building and pushing Docker images..."
103 @for arch in $(ARCHS); do \103 @for arch in $(ARCHS); do \
104- cp $(DIST_DIR)/gordon-linux-$$arch gordon; \104+ $(ENGINE) build --platform linux/$$arch \
105- $(ENGINE) build -t $(REPO):$(TAG)-$$arch .; \105+ --build-arg VERSION="$(VERSION)" \
106- rm gordon; \106+ --build-arg COMMIT="$(COMMIT)" \
107+ --build-arg BUILD_DATE="$(BUILD_DATE)" \
108+ -t $(REPO):$(TAG)-$$arch .; \
107 $(ENGINE) push $(REPO):$(TAG)-$$arch; \109 $(ENGINE) push $(REPO):$(TAG)-$$arch; \
108 done110 done
109 111
M docs/cli/index.md +0 -1
@@ -246,7 +246,6 @@ gordon routes list
246246 Gordon reads configuration from environment variables:
247247
248248 ```bash
249-GORDON_SERVER_PORT=8088 gordon serve
250249 GORDON_LOGGING_LEVEL=debug gordon serve
251250 ```
252251
@@ -246,7 +246,6 @@ gordon routes list
246 Gordon reads configuration from environment variables:246 Gordon reads configuration from environment variables:
247 247
248 ```bash248 ```bash
249-GORDON_SERVER_PORT=8088 gordon serve
250 GORDON_LOGGING_LEVEL=debug gordon serve249 GORDON_LOGGING_LEVEL=debug gordon serve
251 ```250 ```
252 251
M docs/cli/serve.md +0 -1
@@ -57,7 +57,6 @@ gordon serve --config /path/to/gordon.toml
5757 gordon serve -c ./my-config.toml
5858
5959 # With environment override
60-GORDON_SERVER_PORT=8088 gordon serve
6160 GORDON_LOGGING_LEVEL=debug gordon serve
6261 ```
6362
@@ -57,7 +57,6 @@ gordon serve --config /path/to/gordon.toml
57 gordon serve -c ./my-config.toml57 gordon serve -c ./my-config.toml
58 58
59 # With environment override59 # With environment override
60-GORDON_SERVER_PORT=8088 gordon serve
61 GORDON_LOGGING_LEVEL=debug gordon serve60 GORDON_LOGGING_LEVEL=debug gordon serve
62 ```61 ```
63 62
M docs/config/index.md +0 -1
@@ -278,7 +278,6 @@ gordon reload
278278 Configuration values can be overridden with environment variables:
279279
280280 ```bash
281-GORDON_SERVER_PORT=8088 gordon serve
282281 GORDON_LOGGING_LEVEL=debug gordon serve
283282 ```
284283
@@ -278,7 +278,6 @@ gordon reload
278 Configuration values can be overridden with environment variables:278 Configuration values can be overridden with environment variables:
279 279
280 ```bash280 ```bash
281-GORDON_SERVER_PORT=8088 gordon serve
282 GORDON_LOGGING_LEVEL=debug gordon serve281 GORDON_LOGGING_LEVEL=debug gordon serve
283 ```282 ```
284 283
M docs/config/reference.md +0 -1
@@ -360,7 +360,6 @@ GORDON_<SECTION>_<KEY>=value
360360
361361 Examples:
362362 ```bash
363-GORDON_SERVER_PORT=8088
364363 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com
365364 GORDON_AUTH_ENABLED=true
366365 GORDON_LOGGING_LEVEL=debug
@@ -360,7 +360,6 @@ GORDON_<SECTION>_<KEY>=value
360 360
361 Examples:361 Examples:
362 ```bash362 ```bash
363-GORDON_SERVER_PORT=8088
364 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com363 GORDON_SERVER_GORDON_DOMAIN=gordon.example.com
365 GORDON_AUTH_ENABLED=true364 GORDON_AUTH_ENABLED=true
366 GORDON_LOGGING_LEVEL=debug365 GORDON_LOGGING_LEVEL=debug
M docs/installation.md +1 -1
@@ -8,7 +8,7 @@ Detailed installation guide for production environments.
88 - **Architecture**: x86_64 (amd64) or ARM64
99 - **Memory**: 512MB minimum, 1GB recommended
1010 - **Disk**: 10GB minimum for registry storage
11-- **Runtime**: Docker or Podman
11+- **Runtime**: Docker or Podman with a Docker-compatible API version 1.40 or later
1212
1313 ## Download Gordon
1414
@@ -8,7 +8,7 @@ Detailed installation guide for production environments.
8 - **Architecture**: x86_64 (amd64) or ARM648 - **Architecture**: x86_64 (amd64) or ARM64
9 - **Memory**: 512MB minimum, 1GB recommended9 - **Memory**: 512MB minimum, 1GB recommended
10 - **Disk**: 10GB minimum for registry storage10 - **Disk**: 10GB minimum for registry storage
11-- **Runtime**: Docker or Podman11+- **Runtime**: Docker or Podman with a Docker-compatible API version 1.40 or later
12 12
13 ## Download Gordon13 ## Download Gordon
14 14
M docs/upgrading.md +10 -21
@@ -61,34 +61,23 @@ Keep `server.registry_port` for Docker/Podman push and pull traffic; it is separ
6161
6262 ## v2.30.0 to v2.31.0
6363
64-### Breaking: Default Port Changes
64+### Breaking: Public listener migration
6565
66-Gordon now defaults to unprivileged ports that work without root or firewall rules:
66+`server.port` and `server.tls_port` no longer create public listeners. Gordon refuses to start when either legacy key is present without an `[entrypoints]` entry, preventing routes from silently becoming unavailable.
6767
68-| Setting | Old default | New default |
69-|---------|-------------|-------------|
70-| `server.port` | `80` | `8088` |
71-| `server.registry_port` | `5000` | `5000` (unchanged) |
72-| `server.tls_port` | *(n/a)* | `8443` |
73-
74-**If you relied on the old defaults** (no explicit `port` in your config), add the port explicitly:
68+Replace the legacy listener settings with a route-capable entrypoint:
7569
7670 ```toml
7771 [server]
78-port = 80
79-```
80-
81-Or set up firewall port forwarding to the new defaults:
72+registry_port = 5000
73+gordon_domain = "gordon.example.com"
8274
83-```bash
84-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
85-sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
86-sudo firewall-cmd --reload
75+[entrypoints.edge]
76+address = ":443"
77+protocol = "smart_tcp"
8778 ```
8879
89-### New: Internal Certificate Authority
90-
91-Gordon now includes an internal CA for automatic on-demand TLS. Set `tls_port = 0` to disable. See [Server Configuration](./config/server.md#internal-ca-and-tls) for details.
80+Keep `server.registry_port` for Docker and Podman registry traffic. For public TLS, configure `[tls.acme]` or static certificates; do not use `server.tls_port`.
9281
9382 ### Required for Cloudflare/Proxy Setups: `proxy_allowed_ips`
9483
@@ -105,7 +94,7 @@ proxy_allowed_ips = [
10594 ]
10695 ```
10796
108-Without this, all proxied HTTP traffic returns `403 Forbidden`. Set `tls_port = 0` to disable the internal CA and skip this requirement.
97+Without this, all proxied HTTP traffic returns `403 Forbidden`. Remove TLS-capable entrypoints to disable the internal CA and skip this requirement.
10998
11099 ### Breaking: `server.gordon_domain` Replaces `server.registry_domain`
111100
@@ -61,34 +61,23 @@ Keep `server.registry_port` for Docker/Podman push and pull traffic; it is separ
61 61
62 ## v2.30.0 to v2.31.062 ## v2.30.0 to v2.31.0
63 63
64-### Breaking: Default Port Changes64+### Breaking: Public listener migration
65 65
66-Gordon now defaults to unprivileged ports that work without root or firewall rules:66+`server.port` and `server.tls_port` no longer create public listeners. Gordon refuses to start when either legacy key is present without an `[entrypoints]` entry, preventing routes from silently becoming unavailable.
67 67
68-| Setting | Old default | New default |68+Replace the legacy listener settings with a route-capable entrypoint:
69-|---------|-------------|-------------|
70-| `server.port` | `80` | `8088` |
71-| `server.registry_port` | `5000` | `5000` (unchanged) |
72-| `server.tls_port` | *(n/a)* | `8443` |
73-
74-**If you relied on the old defaults** (no explicit `port` in your config), add the port explicitly:
75 69
76 ```toml70 ```toml
77 [server]71 [server]
78-port = 8072+registry_port = 5000
79-```73+gordon_domain = "gordon.example.com"
80-
81-Or set up firewall port forwarding to the new defaults:
82 74
83-```bash75+[entrypoints.edge]
84-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=808876+address = ":443"
85-sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=844377+protocol = "smart_tcp"
86-sudo firewall-cmd --reload
87 ```78 ```
88 79
89-### New: Internal Certificate Authority80+Keep `server.registry_port` for Docker and Podman registry traffic. For public TLS, configure `[tls.acme]` or static certificates; do not use `server.tls_port`.
90-
91-Gordon now includes an internal CA for automatic on-demand TLS. Set `tls_port = 0` to disable. See [Server Configuration](./config/server.md#internal-ca-and-tls) for details.
92 81
93 ### Required for Cloudflare/Proxy Setups: `proxy_allowed_ips`82 ### Required for Cloudflare/Proxy Setups: `proxy_allowed_ips`
94 83
@@ -105,7 +94,7 @@ proxy_allowed_ips = [
105 ]94 ]
106 ```95 ```
107 96
108-Without this, all proxied HTTP traffic returns `403 Forbidden`. Set `tls_port = 0` to disable the internal CA and skip this requirement.97+Without this, all proxied HTTP traffic returns `403 Forbidden`. Remove TLS-capable entrypoints to disable the internal CA and skip this requirement.
109 98
110 ### Breaking: `server.gordon_domain` Replaces `server.registry_domain`99 ### Breaking: `server.gordon_domain` Replaces `server.registry_domain`
111 100
M go.mod +3 -8
@@ -13,8 +13,6 @@ require (
1313 github.com/charmbracelet/lipgloss v1.1.0
1414 github.com/containerd/errdefs v1.0.0
1515 github.com/coreos/go-systemd/v22 v22.7.0
16- github.com/docker/docker v28.5.2+incompatible
17- github.com/docker/go-connections v0.8.1
1816 github.com/docker/go-units v0.5.0
1917 github.com/fsnotify/fsnotify v1.10.1 // direct
2018 github.com/go-acme/lego/v4 v4.35.2
@@ -23,6 +21,8 @@ require (
2321 github.com/google/uuid v1.6.0
2422 github.com/mattn/go-isatty v0.0.24
2523 github.com/mattn/go-runewidth v0.0.28
24+ github.com/moby/moby/api v1.55.0
25+ github.com/moby/moby/client v0.5.1
2626 github.com/muesli/termenv v0.16.0
2727 github.com/pelletier/go-toml/v2 v2.4.3
2828 github.com/rivo/uniseg v0.4.7
@@ -78,10 +78,10 @@ require (
7878 github.com/clipperhouse/displaywidth v0.11.0 // indirect
7979 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
8080 github.com/containerd/errdefs/pkg v0.3.0 // indirect
81- github.com/containerd/log v0.1.0 // indirect
8281 github.com/distribution/reference v0.6.0 // indirect
8382 github.com/docker/cli v29.6.2+incompatible // indirect
8483 github.com/docker/docker-credential-helpers v0.9.5 // indirect
84+ github.com/docker/go-connections v0.8.1 // indirect
8585 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
8686 github.com/felixge/httpsnoop v1.1.0 // indirect
8787 github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@@ -96,14 +96,10 @@ require (
9696 github.com/mattn/go-localereader v0.0.1 // indirect
9797 github.com/miekg/dns v1.1.72 // indirect
9898 github.com/moby/docker-image-spec v1.3.1 // indirect
99- github.com/moby/sys/atomicwriter v0.1.0 // indirect
100- github.com/moby/term v0.5.2 // indirect
101- github.com/morikuni/aec v1.1.0 // indirect
10299 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
103100 github.com/muesli/cancelreader v0.2.2 // indirect
104101 github.com/opencontainers/go-digest v1.0.0 // indirect
105102 github.com/opencontainers/image-spec v1.1.1 // indirect
106- github.com/pkg/errors v0.9.1 // indirect
107103 github.com/sagikazarmark/locafero v0.12.0 // indirect
108104 github.com/sirupsen/logrus v1.9.4 // indirect
109105 github.com/spf13/cast v1.10.0 // indirect
@@ -124,6 +120,5 @@ require (
124120 google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
125121 google.golang.org/grpc v1.83.0 // indirect
126122 google.golang.org/protobuf v1.36.11 // indirect
127- gotest.tools/v3 v3.5.2 // indirect
128123 howett.net/plist v1.0.1 // indirect
129124 )
@@ -13,8 +13,6 @@ require (
13 github.com/charmbracelet/lipgloss v1.1.013 github.com/charmbracelet/lipgloss v1.1.0
14 github.com/containerd/errdefs v1.0.014 github.com/containerd/errdefs v1.0.0
15 github.com/coreos/go-systemd/v22 v22.7.015 github.com/coreos/go-systemd/v22 v22.7.0
16- github.com/docker/docker v28.5.2+incompatible
17- github.com/docker/go-connections v0.8.1
18 github.com/docker/go-units v0.5.016 github.com/docker/go-units v0.5.0
19 github.com/fsnotify/fsnotify v1.10.1 // direct17 github.com/fsnotify/fsnotify v1.10.1 // direct
20 github.com/go-acme/lego/v4 v4.35.218 github.com/go-acme/lego/v4 v4.35.2
@@ -23,6 +21,8 @@ require (
23 github.com/google/uuid v1.6.021 github.com/google/uuid v1.6.0
24 github.com/mattn/go-isatty v0.0.2422 github.com/mattn/go-isatty v0.0.24
25 github.com/mattn/go-runewidth v0.0.2823 github.com/mattn/go-runewidth v0.0.28
24+ github.com/moby/moby/api v1.55.0
25+ github.com/moby/moby/client v0.5.1
26 github.com/muesli/termenv v0.16.026 github.com/muesli/termenv v0.16.0
27 github.com/pelletier/go-toml/v2 v2.4.327 github.com/pelletier/go-toml/v2 v2.4.3
28 github.com/rivo/uniseg v0.4.728 github.com/rivo/uniseg v0.4.7
@@ -78,10 +78,10 @@ require (
78 github.com/clipperhouse/displaywidth v0.11.0 // indirect78 github.com/clipperhouse/displaywidth v0.11.0 // indirect
79 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect79 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
80 github.com/containerd/errdefs/pkg v0.3.0 // indirect80 github.com/containerd/errdefs/pkg v0.3.0 // indirect
81- github.com/containerd/log v0.1.0 // indirect
82 github.com/distribution/reference v0.6.0 // indirect81 github.com/distribution/reference v0.6.0 // indirect
83 github.com/docker/cli v29.6.2+incompatible // indirect82 github.com/docker/cli v29.6.2+incompatible // indirect
84 github.com/docker/docker-credential-helpers v0.9.5 // indirect83 github.com/docker/docker-credential-helpers v0.9.5 // indirect
84+ github.com/docker/go-connections v0.8.1 // indirect
85 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect85 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
86 github.com/felixge/httpsnoop v1.1.0 // indirect86 github.com/felixge/httpsnoop v1.1.0 // indirect
87 github.com/go-jose/go-jose/v4 v4.1.4 // indirect87 github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@@ -96,14 +96,10 @@ require (
96 github.com/mattn/go-localereader v0.0.1 // indirect96 github.com/mattn/go-localereader v0.0.1 // indirect
97 github.com/miekg/dns v1.1.72 // indirect97 github.com/miekg/dns v1.1.72 // indirect
98 github.com/moby/docker-image-spec v1.3.1 // indirect98 github.com/moby/docker-image-spec v1.3.1 // indirect
99- github.com/moby/sys/atomicwriter v0.1.0 // indirect
100- github.com/moby/term v0.5.2 // indirect
101- github.com/morikuni/aec v1.1.0 // indirect
102 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect99 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
103 github.com/muesli/cancelreader v0.2.2 // indirect100 github.com/muesli/cancelreader v0.2.2 // indirect
104 github.com/opencontainers/go-digest v1.0.0 // indirect101 github.com/opencontainers/go-digest v1.0.0 // indirect
105 github.com/opencontainers/image-spec v1.1.1 // indirect102 github.com/opencontainers/image-spec v1.1.1 // indirect
106- github.com/pkg/errors v0.9.1 // indirect
107 github.com/sagikazarmark/locafero v0.12.0 // indirect103 github.com/sagikazarmark/locafero v0.12.0 // indirect
108 github.com/sirupsen/logrus v1.9.4 // indirect104 github.com/sirupsen/logrus v1.9.4 // indirect
109 github.com/spf13/cast v1.10.0 // indirect105 github.com/spf13/cast v1.10.0 // indirect
@@ -124,6 +120,5 @@ require (
124 google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect120 google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
125 google.golang.org/grpc v1.83.0 // indirect121 google.golang.org/grpc v1.83.0 // indirect
126 google.golang.org/protobuf v1.36.11 // indirect122 google.golang.org/protobuf v1.36.11 // indirect
127- gotest.tools/v3 v3.5.2 // indirect
128 howett.net/plist v1.0.1 // indirect123 howett.net/plist v1.0.1 // indirect
129 )124 )
M go.sum +6 -16 generated file: collapsed
M internal/adapters/in/cli/ca.go +2 -2
@@ -100,8 +100,8 @@ func resolveCADataDir() (string, error) {
100100 if err != nil {
101101 return "", err
102102 }
103- if local.GetTLSPort() == 0 {
104- return "", fmt.Errorf("internal TLS is disabled (server.tls_port=0); CA commands are not available — set server.tls_port to enable")
103+ if !local.HasInternalTLS() {
104+ return "", fmt.Errorf("internal TLS is disabled (no TLS-capable entrypoint configured); CA commands are not available — configure a smart_tcp or tls_mux entrypoint")
105105 }
106106 return local.GetDataDir(), nil
107107 }
@@ -100,8 +100,8 @@ func resolveCADataDir() (string, error) {
100 if err != nil {100 if err != nil {
101 return "", err101 return "", err
102 }102 }
103- if local.GetTLSPort() == 0 {103+ if !local.HasInternalTLS() {
104- return "", fmt.Errorf("internal TLS is disabled (server.tls_port=0); CA commands are not available — set server.tls_port to enable")104+ return "", fmt.Errorf("internal TLS is disabled (no TLS-capable entrypoint configured); CA commands are not available — configure a smart_tcp or tls_mux entrypoint")
105 }105 }
106 return local.GetDataDir(), nil106 return local.GetDataDir(), nil
107 }107 }
M internal/adapters/in/cli/local.go +25 -11
@@ -21,10 +21,10 @@ import (
2121
2222 // LocalServices provides direct access to local services for CLI operations.
2323 type LocalServices struct {
24- configSvc in.ConfigService
25- secretSvc in.SecretService
26- dataDir string
27- tlsPort int
24+ configSvc in.ConfigService
25+ secretSvc in.SecretService
26+ dataDir string
27+ tlsEnabled bool
2828 }
2929
3030 // GetConfigService returns the config service.
@@ -42,9 +42,9 @@ func (l *LocalServices) GetDataDir() string {
4242 return l.dataDir
4343 }
4444
45-// GetTLSPort returns the configured TLS port (0 means internal TLS is disabled).
46-func (l *LocalServices) GetTLSPort() int {
47- return l.tlsPort
45+// HasInternalTLS reports whether a TLS-capable entrypoint enables the internal CA.
46+func (l *LocalServices) HasInternalTLS() bool {
47+ return l.tlsEnabled
4848 }
4949
5050 // GetLocalServices creates local services for CLI operations.
@@ -95,13 +95,27 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
9595 secretSvc := secretsSvc.NewService(domainSecretStore, log, nil)
9696
9797 return &LocalServices{
98- configSvc: configSvc,
99- secretSvc: secretSvc,
100- dataDir: dataDir,
101- tlsPort: v.GetInt("server.tls_port"),
98+ configSvc: configSvc,
99+ secretSvc: secretSvc,
100+ dataDir: dataDir,
101+ tlsEnabled: hasLocalTLSCapableEntrypoint(v),
102102 }, nil
103103 }
104104
105+func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool {
106+ for _, rawEntryPoint := range v.GetStringMap("entrypoints") {
107+ entryPoint, ok := rawEntryPoint.(map[string]any)
108+ if !ok {
109+ continue
110+ }
111+ switch strings.TrimSpace(fmt.Sprint(entryPoint["protocol"])) {
112+ case "smart_tcp", "tls_mux":
113+ return true
114+ }
115+ }
116+ return false
117+}
118+
105119 func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) {
106120 backend := resolveLocalSecretsBackend(v)
107121 switch backend {
@@ -21,10 +21,10 @@ import (
21 21
22 // LocalServices provides direct access to local services for CLI operations.22 // LocalServices provides direct access to local services for CLI operations.
23 type LocalServices struct {23 type LocalServices struct {
24- configSvc in.ConfigService24+ configSvc in.ConfigService
25- secretSvc in.SecretService25+ secretSvc in.SecretService
26- dataDir string26+ dataDir string
27- tlsPort int27+ tlsEnabled bool
28 }28 }
29 29
30 // GetConfigService returns the config service.30 // GetConfigService returns the config service.
@@ -42,9 +42,9 @@ func (l *LocalServices) GetDataDir() string {
42 return l.dataDir42 return l.dataDir
43 }43 }
44 44
45-// GetTLSPort returns the configured TLS port (0 means internal TLS is disabled).45+// HasInternalTLS reports whether a TLS-capable entrypoint enables the internal CA.
46-func (l *LocalServices) GetTLSPort() int {46+func (l *LocalServices) HasInternalTLS() bool {
47- return l.tlsPort47+ return l.tlsEnabled
48 }48 }
49 49
50 // GetLocalServices creates local services for CLI operations.50 // GetLocalServices creates local services for CLI operations.
@@ -95,13 +95,27 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
95 secretSvc := secretsSvc.NewService(domainSecretStore, log, nil)95 secretSvc := secretsSvc.NewService(domainSecretStore, log, nil)
96 96
97 return &LocalServices{97 return &LocalServices{
98- configSvc: configSvc,98+ configSvc: configSvc,
99- secretSvc: secretSvc,99+ secretSvc: secretSvc,
100- dataDir: dataDir,100+ dataDir: dataDir,
101- tlsPort: v.GetInt("server.tls_port"),101+ tlsEnabled: hasLocalTLSCapableEntrypoint(v),
102 }, nil102 }, nil
103 }103 }
104 104
105+func hasLocalTLSCapableEntrypoint(v *viper.Viper) bool {
106+ for _, rawEntryPoint := range v.GetStringMap("entrypoints") {
107+ entryPoint, ok := rawEntryPoint.(map[string]any)
108+ if !ok {
109+ continue
110+ }
111+ switch strings.TrimSpace(fmt.Sprint(entryPoint["protocol"])) {
112+ case "smart_tcp", "tls_mux":
113+ return true
114+ }
115+ }
116+ return false
117+}
118+
105 func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) {119 func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) {
106 backend := resolveLocalSecretsBackend(v)120 backend := resolveLocalSecretsBackend(v)
107 switch backend {121 switch backend {
M internal/adapters/in/cli/local_test.go +19 -0
@@ -38,6 +38,25 @@ func TestResolveLocalSecretsBackend(t *testing.T) {
3838 })
3939 }
4040
41+func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
42+ for _, tt := range []struct {
43+ name string
44+ config map[string]any
45+ want bool
46+ }{
47+ {name: "smart tcp", config: map[string]any{"edge": map[string]any{"protocol": "smart_tcp"}}, want: true},
48+ {name: "tls mux", config: map[string]any{"edge": map[string]any{"protocol": "tls_mux"}}, want: true},
49+ {name: "plain tcp", config: map[string]any{"edge": map[string]any{"protocol": "tcp"}}, want: false},
50+ {name: "empty", config: map[string]any{}, want: false},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ v := viper.New()
54+ v.Set("entrypoints", tt.config)
55+ require.Equal(t, tt.want, hasLocalTLSCapableEntrypoint(v))
56+ })
57+ }
58+}
59+
4160 func TestCreateLocalDomainSecretStore_UsesPassStoreForPass(t *testing.T) {
4261 // pass(1) must be available on the system for this test
4362 if _, err := domainsecrets.NewPassStore(zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})); err != nil {
@@ -38,6 +38,25 @@ func TestResolveLocalSecretsBackend(t *testing.T) {
38 })38 })
39 }39 }
40 40
41+func TestHasLocalTLSCapableEntrypoint(t *testing.T) {
42+ for _, tt := range []struct {
43+ name string
44+ config map[string]any
45+ want bool
46+ }{
47+ {name: "smart tcp", config: map[string]any{"edge": map[string]any{"protocol": "smart_tcp"}}, want: true},
48+ {name: "tls mux", config: map[string]any{"edge": map[string]any{"protocol": "tls_mux"}}, want: true},
49+ {name: "plain tcp", config: map[string]any{"edge": map[string]any{"protocol": "tcp"}}, want: false},
50+ {name: "empty", config: map[string]any{}, want: false},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ v := viper.New()
54+ v.Set("entrypoints", tt.config)
55+ require.Equal(t, tt.want, hasLocalTLSCapableEntrypoint(v))
56+ })
57+ }
58+}
59+
41 func TestCreateLocalDomainSecretStore_UsesPassStoreForPass(t *testing.T) {60 func TestCreateLocalDomainSecretStore_UsesPassStoreForPass(t *testing.T) {
42 // pass(1) must be available on the system for this test61 // pass(1) must be available on the system for this test
43 if _, err := domainsecrets.NewPassStore(zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})); err != nil {62 if _, err := domainsecrets.NewPassStore(zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})); err != nil {
M internal/adapters/in/cli/push_image_ops.go +3 -3
@@ -11,7 +11,7 @@ import (
1111 "strings"
1212
1313 cerrdefs "github.com/containerd/errdefs"
14- "github.com/docker/docker/client"
14+ "github.com/moby/moby/client"
1515
1616 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1717 "github.com/bnema/gordon/pkg/registrypush"
@@ -41,7 +41,7 @@ type dockerImageOps struct {
4141
4242 // newDockerImageOps creates a dockerImageOps from the Docker environment.
4343 func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, error) {
44- cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
44+ cli, err := client.New(client.FromEnv)
4545 if err != nil {
4646 return nil, fmt.Errorf("failed to create Docker client: %w", err)
4747 }
@@ -77,7 +77,7 @@ func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps
7777 }
7878
7979 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {
80- if err := d.cli.ImageTag(ctx, sourceRef, targetRef); err != nil {
80+ if _, err := d.cli.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil {
8181 return fmt.Errorf("failed to tag %s as %s: %w", sourceRef, targetRef, err)
8282 }
8383 return nil
@@ -11,7 +11,7 @@ import (
11 "strings"11 "strings"
12 12
13 cerrdefs "github.com/containerd/errdefs"13 cerrdefs "github.com/containerd/errdefs"
14- "github.com/docker/docker/client"14+ "github.com/moby/moby/client"
15 15
16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17 "github.com/bnema/gordon/pkg/registrypush"17 "github.com/bnema/gordon/pkg/registrypush"
@@ -41,7 +41,7 @@ type dockerImageOps struct {
41 41
42 // newDockerImageOps creates a dockerImageOps from the Docker environment.42 // newDockerImageOps creates a dockerImageOps from the Docker environment.
43 func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, error) {43 func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, error) {
44- cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())44+ cli, err := client.New(client.FromEnv)
45 if err != nil {45 if err != nil {
46 return nil, fmt.Errorf("failed to create Docker client: %w", err)46 return nil, fmt.Errorf("failed to create Docker client: %w", err)
47 }47 }
@@ -77,7 +77,7 @@ func newImageOpsForResolvedRemote(resolved *remote.ResolvedRemote) (pushImageOps
77 }77 }
78 78
79 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {79 func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error {
80- if err := d.cli.ImageTag(ctx, sourceRef, targetRef); err != nil {80+ if _, err := d.cli.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil {
81 return fmt.Errorf("failed to tag %s as %s: %w", sourceRef, targetRef, err)81 return fmt.Errorf("failed to tag %s as %s: %w", sourceRef, targetRef, err)
82 }82 }
83 return nil83 return nil
M internal/adapters/out/docker/network_test.go +5 -5
@@ -7,7 +7,7 @@ import (
77 "strings"
88 "testing"
99
10- "github.com/docker/docker/client"
10+ "github.com/moby/moby/client"
1111 "github.com/stretchr/testify/assert"
1212 )
1313
@@ -28,7 +28,7 @@ func TestRuntime_GetContainerNetwork(t *testing.T) {
2828 defer server.Close()
2929
3030 host := strings.TrimPrefix(server.URL, "http://")
31- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
31+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
3232 if err != nil {
3333 t.Fatalf("failed to create docker client: %v", err)
3434 }
@@ -57,7 +57,7 @@ func TestRuntime_GetContainerNetwork_Fallback(t *testing.T) {
5757 defer server.Close()
5858
5959 host := strings.TrimPrefix(server.URL, "http://")
60- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
60+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
6161 if err != nil {
6262 t.Fatalf("failed to create docker client: %v", err)
6363 }
@@ -83,7 +83,7 @@ func TestRuntime_GetContainerNetwork_EmptyNetworks(t *testing.T) {
8383 defer server.Close()
8484
8585 host := strings.TrimPrefix(server.URL, "http://")
86- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
86+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
8787 if err != nil {
8888 t.Fatalf("failed to create docker client: %v", err)
8989 }
@@ -105,7 +105,7 @@ func TestRuntime_GetContainerNetwork_NilNetworkSettings(t *testing.T) {
105105 defer server.Close()
106106
107107 host := strings.TrimPrefix(server.URL, "http://")
108- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
108+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
109109 if err != nil {
110110 t.Fatalf("failed to create docker client: %v", err)
111111 }
@@ -7,7 +7,7 @@ import (
7 "strings"7 "strings"
8 "testing"8 "testing"
9 9
10- "github.com/docker/docker/client"10+ "github.com/moby/moby/client"
11 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12 )12 )
13 13
@@ -28,7 +28,7 @@ func TestRuntime_GetContainerNetwork(t *testing.T) {
28 defer server.Close()28 defer server.Close()
29 29
30 host := strings.TrimPrefix(server.URL, "http://")30 host := strings.TrimPrefix(server.URL, "http://")
31- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))31+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
32 if err != nil {32 if err != nil {
33 t.Fatalf("failed to create docker client: %v", err)33 t.Fatalf("failed to create docker client: %v", err)
34 }34 }
@@ -57,7 +57,7 @@ func TestRuntime_GetContainerNetwork_Fallback(t *testing.T) {
57 defer server.Close()57 defer server.Close()
58 58
59 host := strings.TrimPrefix(server.URL, "http://")59 host := strings.TrimPrefix(server.URL, "http://")
60- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))60+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
61 if err != nil {61 if err != nil {
62 t.Fatalf("failed to create docker client: %v", err)62 t.Fatalf("failed to create docker client: %v", err)
63 }63 }
@@ -83,7 +83,7 @@ func TestRuntime_GetContainerNetwork_EmptyNetworks(t *testing.T) {
83 defer server.Close()83 defer server.Close()
84 84
85 host := strings.TrimPrefix(server.URL, "http://")85 host := strings.TrimPrefix(server.URL, "http://")
86- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))86+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
87 if err != nil {87 if err != nil {
88 t.Fatalf("failed to create docker client: %v", err)88 t.Fatalf("failed to create docker client: %v", err)
89 }89 }
@@ -105,7 +105,7 @@ func TestRuntime_GetContainerNetwork_NilNetworkSettings(t *testing.T) {
105 defer server.Close()105 defer server.Close()
106 106
107 host := strings.TrimPrefix(server.URL, "http://")107 host := strings.TrimPrefix(server.URL, "http://")
108- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))108+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
109 if err != nil {109 if err != nil {
110 t.Fatalf("failed to create docker client: %v", err)110 t.Fatalf("failed to create docker client: %v", err)
111 }111 }
M internal/adapters/out/docker/runtime.go +154 -116
@@ -13,6 +13,7 @@ import (
1313 "io"
1414 "maps"
1515 "math"
16+ "net/netip"
1617 "os"
1718 "path/filepath"
1819 "sort"
@@ -23,18 +24,13 @@ import (
2324
2425 "github.com/bnema/zerowrap"
2526 cerrdefs "github.com/containerd/errdefs"
26- "github.com/docker/docker/api/types/container"
27- "github.com/docker/docker/api/types/filters"
28- "github.com/docker/docker/api/types/image"
29- "github.com/docker/docker/api/types/mount"
30- "github.com/docker/docker/api/types/network"
31- "github.com/docker/docker/api/types/registry"
32- "github.com/docker/docker/api/types/strslice"
33- "github.com/docker/docker/api/types/volume"
34- "github.com/docker/docker/client"
35- "github.com/docker/docker/pkg/stdcopy"
36- "github.com/docker/go-connections/nat"
3727 "github.com/docker/go-units"
28+ "github.com/moby/moby/api/pkg/stdcopy"
29+ "github.com/moby/moby/api/types/container"
30+ "github.com/moby/moby/api/types/mount"
31+ "github.com/moby/moby/api/types/network"
32+ "github.com/moby/moby/api/types/registry"
33+ "github.com/moby/moby/client"
3834
3935 "github.com/bnema/gordon/internal/boundaries/out"
4036 "github.com/bnema/gordon/internal/domain"
@@ -100,7 +96,7 @@ func (f *removeOnCloseFile) Close() error {
10096
10197 // NewRuntime creates a new Docker runtime instance.
10298 func NewRuntime() (*Runtime, error) {
103- cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
99+ cli, err := client.New(client.FromEnv)
104100 if err != nil {
105101 return nil, fmt.Errorf("failed to create Docker client: %w", err)
106102 }
@@ -113,10 +109,7 @@ func NewRuntime() (*Runtime, error) {
113109 // NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path.
114110 func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
115111 host := "unix://" + socketPath
116- cli, err := client.NewClientWithOpts(
117- client.WithHost(host),
118- client.WithAPIVersionNegotiation(),
119- )
112+ cli, err := client.New(client.WithHost(host))
120113 if err != nil {
121114 return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
122115 }
@@ -170,13 +163,13 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
170163 if config.PidsLimit > 0 {
171164 resources.PidsLimit = &config.PidsLimit
172165 }
173- capDrop := strslice.StrSlice{"ALL"}
174- capAdd := strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
166+ capDrop := []string{"ALL"}
167+ capAdd := []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
175168 if config.CapDrop != nil {
176- capDrop = strslice.StrSlice(config.CapDrop)
169+ capDrop = config.CapDrop
177170 }
178171 if config.CapAdd != nil {
179- capAdd = strslice.StrSlice(config.CapAdd)
172+ capAdd = config.CapAdd
180173 }
181174 hostConfig := &container.HostConfig{
182175 PortBindings: portBindings,
@@ -208,8 +201,13 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
208201 }
209202 }
210203
211- // Create the container
212- resp, err := r.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
204+ // Create the container.
205+ resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
206+ Config: containerConfig,
207+ HostConfig: hostConfig,
208+ NetworkingConfig: networkConfig,
209+ Name: config.Name,
210+ })
213211 if err != nil {
214212 return nil, log.WrapErr(err, "failed to create container")
215213 }
@@ -220,38 +218,54 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
220218 return r.InspectContainer(ctx, resp.ID)
221219 }
222220
223-func buildPortBindings(config *domain.ContainerConfig) (nat.PortSet, nat.PortMap, error) {
224- exposedPorts := make(nat.PortSet)
225- portBindings := make(nat.PortMap)
226- addLegacyPortBindings(config.Ports, exposedPorts, portBindings)
221+func buildPortBindings(config *domain.ContainerConfig) (network.PortSet, network.PortMap, error) {
222+ exposedPorts := make(network.PortSet)
223+ portBindings := make(network.PortMap)
224+ if err := addLegacyPortBindings(config.Ports, exposedPorts, portBindings); err != nil {
225+ return nil, nil, err
226+ }
227227 if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil {
228228 return nil, nil, err
229229 }
230230 return exposedPorts, portBindings, nil
231231 }
232232
233-func addLegacyPortBindings(ports []int, exposedPorts nat.PortSet, portBindings nat.PortMap) {
233+func addLegacyPortBindings(ports []int, exposedPorts network.PortSet, portBindings network.PortMap) error {
234234 for _, port := range ports {
235- containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
235+ containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", port))
236+ if err != nil {
237+ return fmt.Errorf("invalid container port %d: %w", port, err)
238+ }
236239 exposedPorts[containerPort] = struct{}{}
237240
238241 // Bind to random available port on localhost only.
239242 // SECURITY: Using 127.0.0.1 prevents direct access from the network,
240243 // forcing all traffic through Gordon's reverse proxy where auth,
241244 // rate limiting, and security headers are applied.
242- portBindings[containerPort] = []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: "0"}}
245+ portBindings[containerPort] = []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "0"}}
243246 }
247+ return nil
244248 }
245249
246-func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts nat.PortSet, portBindings nat.PortMap) error {
250+func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts network.PortSet, portBindings network.PortMap) error {
247251 for _, publish := range publishes {
248252 if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {
249253 return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)
250254 }
251- containerPort := nat.Port(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
255+ containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
256+ if err != nil {
257+ return fmt.Errorf("invalid container port %d: %w", publish.ContainerPort, err)
258+ }
259+ var hostIP netip.Addr
260+ if publish.HostIP != "" {
261+ hostIP, err = netip.ParseAddr(publish.HostIP)
262+ if err != nil {
263+ return fmt.Errorf("invalid host IP %q: %w", publish.HostIP, err)
264+ }
265+ }
252266 exposedPorts[containerPort] = struct{}{}
253- portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{
254- HostIP: publish.HostIP,
267+ portBindings[containerPort] = append(portBindings[containerPort], network.PortBinding{
268+ HostIP: hostIP,
255269 HostPort: strconv.Itoa(publish.HostPort),
256270 })
257271 }
@@ -283,7 +297,7 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error
283297 })
284298 log := zerowrap.FromCtx(ctx)
285299
286- err := r.client.ContainerStart(ctx, containerID, container.StartOptions{})
300+ _, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
287301 if err != nil {
288302 return log.WrapErr(err, "failed to start container")
289303 }
@@ -302,7 +316,8 @@ func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) erro
302316 })
303317 log := zerowrap.FromCtx(ctx)
304318
305- statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
319+ waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
320+ statusCh, errCh := waitResult.Result, waitResult.Error
306321 select {
307322 case err := <-errCh:
308323 if err != nil {
@@ -326,7 +341,7 @@ func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
326341 log := zerowrap.FromCtx(ctx)
327342
328343 timeout := 20 // 20 seconds before SIGKILL
329- err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
344+ _, err := r.client.ContainerStop(ctx, containerID, client.ContainerStopOptions{Timeout: &timeout})
330345 if err != nil {
331346 return log.WrapErr(err, "failed to stop container")
332347 }
@@ -346,7 +361,7 @@ func (r *Runtime) RestartContainer(ctx context.Context, containerID string) erro
346361 log := zerowrap.FromCtx(ctx)
347362
348363 timeout := 20 // 20 seconds before SIGKILL
349- err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
364+ _, err := r.client.ContainerRestart(ctx, containerID, client.ContainerRestartOptions{Timeout: &timeout})
350365 if err != nil {
351366 return log.WrapErr(err, "failed to restart container")
352367 }
@@ -366,7 +381,7 @@ func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force
366381 })
367382 log := zerowrap.FromCtx(ctx)
368383
369- err := r.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
384+ _, err := r.client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: force})
370385 if err != nil {
371386 return log.WrapErr(err, "failed to remove container")
372387 }
@@ -386,7 +401,7 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri
386401 })
387402 log := zerowrap.FromCtx(ctx)
388403
389- err := r.client.ContainerRename(ctx, containerID, newName)
404+ _, err := r.client.ContainerRename(ctx, containerID, client.ContainerRenameOptions{NewName: newName})
390405 if err != nil {
391406 return log.WrapErr(err, "failed to rename container")
392407 }
@@ -405,13 +420,13 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
405420 })
406421 log := zerowrap.FromCtx(ctx)
407422
408- containers, err := r.client.ContainerList(ctx, container.ListOptions{All: all})
423+ containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: all})
409424 if err != nil {
410425 return nil, log.WrapErr(err, "failed to list containers")
411426 }
412427
413428 var result []*domain.Container
414- for _, c := range containers {
429+ for _, c := range containerList.Items {
415430 // Extract ports
416431 var ports []int
417432 for _, port := range c.Ports {
@@ -442,7 +457,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
442457 Image: c.Image,
443458 ImageID: c.ImageID,
444459 Name: name,
445- Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
460+ Status: string(c.State), // Use State (e.g., "running") not Status (e.g., "Up 2 days")
446461 Ports: ports,
447462 Labels: c.Labels,
448463 VolumeMounts: volumeMounts,
@@ -463,10 +478,11 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
463478 })
464479 log := zerowrap.FromCtx(ctx)
465480
466- resp, err := r.client.ContainerInspect(ctx, containerID)
481+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
467482 if err != nil {
468483 return nil, log.WrapErr(err, "failed to inspect container")
469484 }
485+ resp := inspectResult.Container
470486
471487 // Extract published ports
472488 var ports []int
@@ -502,7 +518,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
502518 Image: resp.Config.Image,
503519 ImageID: resp.Image,
504520 Name: name,
505- Status: resp.State.Status,
521+ Status: string(resp.State.Status),
506522 ExitCode: resp.State.ExitCode,
507523 Ports: ports,
508524 Labels: resp.Config.Labels,
@@ -521,7 +537,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll
521537 })
522538 log := zerowrap.FromCtx(ctx)
523539
524- logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{
540+ logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{
525541 ShowStdout: true,
526542 ShowStderr: true,
527543 Follow: follow,
@@ -547,7 +563,7 @@ func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
547563
548564 log.Info().Msg("pulling image")
549565
550- reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{})
566+ reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{})
551567 if err != nil {
552568 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
553569 }
@@ -605,7 +621,7 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
605621 Msg("auth config for pull")
606622
607623 // Pull with authentication
608- reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{
624+ reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{
609625 RegistryAuth: authStr,
610626 })
611627 if err != nil {
@@ -634,7 +650,7 @@ func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) err
634650 })
635651 log := zerowrap.FromCtx(ctx)
636652
637- if err := r.client.ImageTag(ctx, sourceRef, targetRef); err != nil {
653+ if _, err := r.client.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil {
638654 return log.WrapErr(err, "failed to tag image")
639655 }
640656
@@ -653,7 +669,7 @@ func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error {
653669 log := zerowrap.FromCtx(ctx)
654670
655671 // ImageRemove with PruneChildren=false only removes the tag, not the image layers
656- _, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{
672+ _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{
657673 Force: false,
658674 PruneChildren: false,
659675 })
@@ -676,7 +692,7 @@ func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool)
676692 })
677693 log := zerowrap.FromCtx(ctx)
678694
679- _, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
695+ _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{Force: force})
680696 if err != nil {
681697 return log.WrapErr(err, "failed to remove image")
682698 }
@@ -694,13 +710,13 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
694710 })
695711 log := zerowrap.FromCtx(ctx)
696712
697- images, err := r.client.ImageList(ctx, image.ListOptions{})
713+ imageList, err := r.client.ImageList(ctx, client.ImageListOptions{})
698714 if err != nil {
699715 return nil, log.WrapErr(err, "failed to list images")
700716 }
701717
702718 var result []string
703- for _, img := range images {
719+ for _, img := range imageList.Items {
704720 for _, tag := range img.RepoTags {
705721 if tag != "<none>:<none>" {
706722 result = append(result, tag)
@@ -720,13 +736,13 @@ func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDet
720736 })
721737 log := zerowrap.FromCtx(ctx)
722738
723- images, err := r.client.ImageList(ctx, image.ListOptions{All: true})
739+ imageList, err := r.client.ImageList(ctx, client.ImageListOptions{All: true})
724740 if err != nil {
725741 return nil, log.WrapErr(err, "failed to list detailed images")
726742 }
727743
728- result := make([]runtimepkg.ImageDetail, 0, len(images))
729- for _, img := range images {
744+ result := make([]runtimepkg.ImageDetail, 0, len(imageList.Items))
745+ for _, img := range imageList.Items {
730746 result = append(result, runtimepkg.ImageDetail{
731747 ID: img.ID,
732748 RepoTags: img.RepoTags,
@@ -748,19 +764,18 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk
748764 })
749765 log := zerowrap.FromCtx(ctx)
750766
751- pruneFilters := filters.NewArgs()
752- pruneFilters.Add("label", domain.LabelManaged+"=true")
767+ pruneFilters := make(client.Filters).Add("label", domain.LabelManaged+"=true")
753768 if danglingOnly {
754769 pruneFilters.Add("dangling", "true")
755770 }
756771
757- pruneResult, err := r.client.ImagesPrune(ctx, pruneFilters)
772+ pruneResult, err := r.client.ImagePrune(ctx, client.ImagePruneOptions{Filters: pruneFilters})
758773 if err != nil {
759774 return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images")
760775 }
761776
762- deletedIDs := make([]string, 0, len(pruneResult.ImagesDeleted))
763- for _, deleted := range pruneResult.ImagesDeleted {
777+ deletedIDs := make([]string, 0, len(pruneResult.Report.ImagesDeleted))
778+ for _, deleted := range pruneResult.Report.ImagesDeleted {
764779 if deleted.Deleted != "" {
765780 deletedIDs = append(deletedIDs, deleted.Deleted)
766781 }
@@ -769,7 +784,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk
769784 }
770785 }
771786
772- spaceReclaimed := pruneResult.SpaceReclaimed
787+ spaceReclaimed := pruneResult.Report.SpaceReclaimed
773788 if spaceReclaimed > math.MaxInt64 {
774789 log.Warn().
775790 Uint64("space_reclaimed_bytes", spaceReclaimed).
@@ -796,7 +811,7 @@ func (r *Runtime) Ping(ctx context.Context) error {
796811 })
797812 log := zerowrap.FromCtx(ctx)
798813
799- _, err := r.client.Ping(ctx)
814+ _, err := r.client.Ping(ctx, client.PingOptions{})
800815 if err != nil {
801816 return log.WrapErr(err, "Docker ping failed")
802817 }
@@ -812,7 +827,7 @@ func (r *Runtime) Version(ctx context.Context) (string, error) {
812827 })
813828 log := zerowrap.FromCtx(ctx)
814829
815- version, err := r.client.ServerVersion(ctx)
830+ version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{})
816831 if err != nil {
817832 return "", log.WrapErr(err, "failed to get Docker version")
818833 }
@@ -838,10 +853,11 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri
838853 })
839854 log := zerowrap.FromCtx(ctx)
840855
841- resp, err := r.client.ContainerInspect(ctx, containerID)
856+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
842857 if err != nil {
843858 return "", false, log.WrapErr(err, "failed to inspect container")
844859 }
860+ resp := inspectResult.Container
845861
846862 // Detect configured healthchecks from container config rather than relying on
847863 // State.Health presence. Some runtimes can expose an empty State.Health struct
@@ -854,7 +870,7 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri
854870 return "", true, nil
855871 }
856872
857- return resp.State.Health.Status, true, nil
873+ return string(resp.State.Health.Status), true, nil
858874 }
859875
860876 func hasConfiguredHealthcheck(cfg *container.Config) bool {
@@ -876,16 +892,20 @@ func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, inte
876892 })
877893 log := zerowrap.FromCtx(ctx)
878894
879- resp, err := r.client.ContainerInspect(ctx, containerID)
895+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
880896 if err != nil {
881897 return 0, log.WrapErr(err, "failed to inspect container")
882898 }
899+ resp := inspectResult.Container
883900
884901 if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
885902 return 0, fmt.Errorf("no port mappings found for container %s", containerID)
886903 }
887904
888- containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
905+ containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", internalPort))
906+ if err != nil {
907+ return 0, fmt.Errorf("invalid container port %d: %w", internalPort, err)
908+ }
889909 bindings, exists := resp.NetworkSettings.Ports[containerPort]
890910 if !exists || len(bindings) == 0 {
891911 return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
@@ -975,16 +995,17 @@ func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID stri
975995 })
976996 log := zerowrap.FromCtx(ctx)
977997
978- resp, err := r.client.ContainerInspect(ctx, containerID)
998+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
979999 if err != nil {
9801000 return nil, log.WrapErr(err, "failed to inspect container")
9811001 }
1002+ resp := inspectResult.Container
9821003
9831004 var ports []int
9841005 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
9851006 for portSpec := range resp.NetworkSettings.Ports {
986- // Parse port from format like "80/tcp"
987- portStr := strings.Split(string(portSpec), "/")[0]
1007+ // Parse port from format like "80/tcp".
1008+ portStr := portSpec.Port()
9881009 if port, err := strconv.Atoi(portStr); err == nil {
9891010 ports = append(ports, port)
9901011 }
@@ -1004,18 +1025,19 @@ func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID strin
10041025 })
10051026 log := zerowrap.FromCtx(ctx)
10061027
1007- resp, err := r.client.ContainerInspect(ctx, containerID)
1028+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
10081029 if err != nil {
10091030 return "", 0, log.WrapErr(err, "failed to inspect container")
10101031 }
1032+ resp := inspectResult.Container
10111033
10121034 // Get container's internal IP address
10131035 var containerIP string
10141036 if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
10151037 // Use the first available network (usually bridge or custom network)
10161038 for _, net := range resp.NetworkSettings.Networks {
1017- if net.IPAddress != "" {
1018- containerIP = net.IPAddress
1039+ if net.IPAddress.IsValid() {
1040+ containerIP = net.IPAddress.String()
10191041 break
10201042 }
10211043 }
@@ -1029,9 +1051,8 @@ func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID strin
10291051 var exposedPorts []int
10301052 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
10311053 for portSpec := range resp.NetworkSettings.Ports {
1032- // Parse port from format like "80/tcp"
1033- portStr := strings.Split(string(portSpec), "/")[0]
1034- if port, err := strconv.Atoi(portStr); err == nil {
1054+ // Parse port from format like "80/tcp".
1055+ if port, err := strconv.Atoi(portSpec.Port()); err == nil {
10351056 exposedPorts = append(exposedPorts, port)
10361057 }
10371058 }
@@ -1064,12 +1085,12 @@ func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (
10641085 })
10651086 log := zerowrap.FromCtx(ctx)
10661087
1067- resp, err := r.client.ContainerInspect(ctx, containerID)
1088+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
10681089 if err != nil {
10691090 return "", log.WrapErr(err, "failed to inspect container")
10701091 }
10711092
1072- networks := resp.NetworkSettings
1093+ networks := inspectResult.Container.NetworkSettings
10731094 if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
10741095 return "bridge", nil
10751096 }
@@ -1145,7 +1166,7 @@ func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, er
11451166 })
11461167 log := zerowrap.FromCtx(ctx)
11471168
1148- _, err := r.client.VolumeInspect(ctx, volumeName)
1169+ _, err := r.client.VolumeInspect(ctx, volumeName, client.VolumeInspectOptions{})
11491170 if err != nil {
11501171 if cerrdefs.IsNotFound(err) {
11511172 return false, nil
@@ -1165,7 +1186,7 @@ func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error {
11651186 })
11661187 log := zerowrap.FromCtx(ctx)
11671188
1168- _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{
1189+ _, err := r.client.VolumeCreate(ctx, client.VolumeCreateOptions{
11691190 Name: volumeName,
11701191 Labels: map[string]string{
11711192 domain.LabelManaged: "true",
@@ -1191,7 +1212,7 @@ func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force boo
11911212 })
11921213 log := zerowrap.FromCtx(ctx)
11931214
1194- err := r.client.VolumeRemove(ctx, volumeName, force)
1215+ _, err := r.client.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
11951216 if err != nil {
11961217 if cerrdefs.IsNotFound(err) {
11971218 log.Debug().Msg("volume not found, already removed")
@@ -1206,20 +1227,27 @@ func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force boo
12061227
12071228 // ListVolumes returns all named volumes with their usage status.
12081229 func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1209- volumeList, err := r.client.VolumeList(ctx, volume.ListOptions{})
1230+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1231+ zerowrap.FieldLayer: "adapter",
1232+ zerowrap.FieldAdapter: "docker",
1233+ zerowrap.FieldAction: "ListVolumes",
1234+ })
1235+ log := zerowrap.FromCtx(ctx)
1236+
1237+ volumeList, err := r.client.VolumeList(ctx, client.VolumeListOptions{})
12101238 if err != nil {
1211- return nil, fmt.Errorf("failed to list volumes: %w", err)
1239+ return nil, log.WrapErr(err, "failed to list volumes")
12121240 }
12131241
1214- // Get all containers to determine volume usage
1215- containers, err := r.client.ContainerList(ctx, container.ListOptions{All: true})
1242+ // Get all containers to determine volume usage.
1243+ containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: true})
12161244 if err != nil {
1217- return nil, fmt.Errorf("failed to list containers for volume usage: %w", err)
1245+ return nil, log.WrapErr(err, "failed to list containers for volume usage")
12181246 }
12191247
1220- // Build a map of volume name -> container names
1248+ // Build a map of volume name -> container names.
12211249 volumeUsers := make(map[string][]string)
1222- for _, c := range containers {
1250+ for _, c := range containerList.Items {
12231251 for _, m := range c.Mounts {
12241252 if m.Type == "volume" {
12251253 name := ""
@@ -1232,11 +1260,7 @@ func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
12321260 }
12331261
12341262 var result []*domain.VolumeInfo
1235- for _, v := range volumeList.Volumes {
1236- if v == nil {
1237- continue
1238- }
1239-
1263+ for _, v := range volumeList.Items {
12401264 var size int64
12411265 if v.UsageData != nil {
12421266 size = v.UsageData.Size
@@ -1281,7 +1305,7 @@ func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArch
12811305 if err != nil {
12821306 return nil, err
12831307 }
1284- if err := r.client.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
1308+ if _, err := r.client.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
12851309 _ = cleanup()
12861310 return nil, log.WrapErr(err, "failed to start volume archive helper container")
12871311 }
@@ -1314,14 +1338,18 @@ func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef s
13141338 return nil
13151339 }
13161340
1317-func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (container.CreateResponse, func() error, error) {
1341+func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (client.ContainerCreateResult, func() error, error) {
13181342 cmd, err := volumeArchiveCommand(req.Compression)
13191343 if err != nil {
1320- return container.CreateResponse{}, nil, err
1344+ return client.ContainerCreateResult{}, nil, err
13211345 }
1322- created, err := r.client.ContainerCreate(ctx, volumeArchiveContainerConfig(req.HelperImage, cmd), volumeArchiveHostConfig(req.VolumeName), nil, nil, fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()))
1346+ created, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
1347+ Config: volumeArchiveContainerConfig(req.HelperImage, cmd),
1348+ HostConfig: volumeArchiveHostConfig(req.VolumeName),
1349+ Name: fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()),
1350+ })
13231351 if err != nil {
1324- return container.CreateResponse{}, nil, log.WrapErr(err, "failed to create volume archive helper container")
1352+ return client.ContainerCreateResult{}, nil, log.WrapErr(err, "failed to create volume archive helper container")
13251353 }
13261354 var cleanupOnce sync.Once
13271355 cleanup := func() error {
@@ -1363,11 +1391,12 @@ func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
13631391 }
13641392
13651393 func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1366- logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
1394+ logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
13671395 if err != nil {
13681396 return nil, log.WrapErr(err, "failed to attach volume archive stream")
13691397 }
1370- statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
1398+ waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
1399+ statusCh, errCh := waitResult.Result, waitResult.Error
13711400 pr, pw := io.Pipe()
13721401 go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
13731402 return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil
@@ -1538,7 +1567,7 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.
15381567 maps.Copy(labels, config.Labels)
15391568 labels[domain.LabelManaged] = "true"
15401569
1541- createOptions := network.CreateOptions{
1570+ createOptions := client.NetworkCreateOptions{
15421571 Driver: driver,
15431572 Internal: config.Internal,
15441573 Labels: labels,
@@ -1563,7 +1592,7 @@ func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
15631592 })
15641593 log := zerowrap.FromCtx(ctx)
15651594
1566- err := r.client.NetworkRemove(ctx, name)
1595+ _, err := r.client.NetworkRemove(ctx, name, client.NetworkRemoveOptions{})
15671596 if err != nil {
15681597 if cerrdefs.IsNotFound(err) {
15691598 log.Debug().Msg("network not found, already removed")
@@ -1585,15 +1614,19 @@ func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro
15851614 })
15861615 log := zerowrap.FromCtx(ctx)
15871616
1588- networks, err := r.client.NetworkList(ctx, network.ListOptions{})
1617+ networkList, err := r.client.NetworkList(ctx, client.NetworkListOptions{})
15891618 if err != nil {
15901619 return nil, log.WrapErr(err, "failed to list networks")
15911620 }
15921621
15931622 var result []*domain.NetworkInfo
1594- for _, net := range networks {
1623+ for _, net := range networkList.Items {
1624+ networkInspect, err := r.client.NetworkInspect(ctx, net.ID, client.NetworkInspectOptions{})
1625+ if err != nil {
1626+ return nil, log.WrapErr(err, "failed to inspect network")
1627+ }
15951628 var containers []string
1596- for containerID := range net.Containers {
1629+ for containerID := range networkInspect.Network.Containers {
15971630 containers = append(containers, containerID)
15981631 }
15991632
@@ -1619,7 +1652,7 @@ func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error)
16191652 })
16201653 log := zerowrap.FromCtx(ctx)
16211654
1622- _, err := r.client.NetworkInspect(ctx, name, network.InspectOptions{})
1655+ _, err := r.client.NetworkInspect(ctx, name, client.NetworkInspectOptions{})
16231656 if err != nil {
16241657 if cerrdefs.IsNotFound(err) {
16251658 return false, nil
@@ -1640,7 +1673,10 @@ func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName,
16401673 })
16411674 log := zerowrap.FromCtx(ctx)
16421675
1643- err := r.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
1676+ _, err := r.client.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
1677+ Container: containerName,
1678+ EndpointConfig: &network.EndpointSettings{},
1679+ })
16441680 if err != nil {
16451681 return log.WrapErr(err, "failed to connect container to network")
16461682 }
@@ -1660,7 +1696,9 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN
16601696 })
16611697 log := zerowrap.FromCtx(ctx)
16621698
1663- err := r.client.NetworkDisconnect(ctx, networkName, containerName, false)
1699+ _, err := r.client.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{
1700+ Container: containerName,
1701+ })
16641702 if err != nil {
16651703 return log.WrapErr(err, "failed to disconnect container from network")
16661704 }
@@ -1685,7 +1723,7 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
16851723 })
16861724 log := zerowrap.FromCtx(ctx)
16871725
1688- execResp, err := r.client.ContainerExecCreate(ctx, containerID, container.ExecOptions{
1726+ execResp, err := r.client.ExecCreate(ctx, containerID, client.ExecCreateOptions{
16891727 Cmd: cmd,
16901728 AttachStdout: true,
16911729 AttachStderr: true,
@@ -1701,7 +1739,7 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
17011739 defer cancel()
17021740 }
17031741
1704- attachResp, err := r.client.ContainerExecAttach(attachCtx, execResp.ID, container.ExecAttachOptions{})
1742+ attachResp, err := r.client.ExecAttach(attachCtx, execResp.ID, client.ExecAttachOptions{})
17051743 if err != nil {
17061744 return nil, log.WrapErr(err, "failed to attach to exec")
17071745 }
@@ -1715,7 +1753,7 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
17151753 return nil, log.WrapErr(err, "failed to read exec output")
17161754 }
17171755
1718- inspectResp, err := r.client.ContainerExecInspect(ctx, execResp.ID)
1756+ inspectResp, err := r.client.ExecInspect(ctx, execResp.ID, client.ExecInspectOptions{})
17191757 if err != nil {
17201758 return nil, log.WrapErr(err, "failed to inspect exec result")
17211759 }
@@ -1787,15 +1825,15 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
17871825 })
17881826 log := zerowrap.FromCtx(ctx)
17891827
1790- tarReader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1828+ copyResult, err := r.client.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{SourcePath: srcPath})
17911829 if err != nil {
17921830 return nil, log.WrapErr(err, "failed to copy from container")
17931831 }
17941832
17951833 // The response is a tar archive - we need to extract the requested file stream.
1796- reader, err := extractFileFromTar(tarReader, srcPath)
1834+ reader, err := extractFileFromTar(copyResult.Content, srcPath)
17971835 if err != nil {
1798- _ = tarReader.Close()
1836+ _ = copyResult.Content.Close()
17991837 return nil, log.WrapErr(err, "failed to extract file from tar")
18001838 }
18011839
@@ -1928,7 +1966,7 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
19281966 Cmd: []string{"true"}, // Dummy command
19291967 }
19301968
1931- resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "")
1969+ resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{Config: containerConfig})
19321970 if err != nil {
19331971 return nil, log.WrapErr(err, "failed to create temporary container")
19341972 }
@@ -1938,7 +1976,7 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
19381976
19391977 // Ensure cleanup
19401978 defer func() {
1941- if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil {
1979+ if _, err := r.client.ContainerRemove(ctx, tempContainerID, client.ContainerRemoveOptions{Force: true}); err != nil {
19421980 log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
19431981 }
19441982 }()
@@ -13,6 +13,7 @@ import (
13 "io"13 "io"
14 "maps"14 "maps"
15 "math"15 "math"
16+ "net/netip"
16 "os"17 "os"
17 "path/filepath"18 "path/filepath"
18 "sort"19 "sort"
@@ -23,18 +24,13 @@ import (
23 24
24 "github.com/bnema/zerowrap"25 "github.com/bnema/zerowrap"
25 cerrdefs "github.com/containerd/errdefs"26 cerrdefs "github.com/containerd/errdefs"
26- "github.com/docker/docker/api/types/container"
27- "github.com/docker/docker/api/types/filters"
28- "github.com/docker/docker/api/types/image"
29- "github.com/docker/docker/api/types/mount"
30- "github.com/docker/docker/api/types/network"
31- "github.com/docker/docker/api/types/registry"
32- "github.com/docker/docker/api/types/strslice"
33- "github.com/docker/docker/api/types/volume"
34- "github.com/docker/docker/client"
35- "github.com/docker/docker/pkg/stdcopy"
36- "github.com/docker/go-connections/nat"
37 "github.com/docker/go-units"27 "github.com/docker/go-units"
28+ "github.com/moby/moby/api/pkg/stdcopy"
29+ "github.com/moby/moby/api/types/container"
30+ "github.com/moby/moby/api/types/mount"
31+ "github.com/moby/moby/api/types/network"
32+ "github.com/moby/moby/api/types/registry"
33+ "github.com/moby/moby/client"
38 34
39 "github.com/bnema/gordon/internal/boundaries/out"35 "github.com/bnema/gordon/internal/boundaries/out"
40 "github.com/bnema/gordon/internal/domain"36 "github.com/bnema/gordon/internal/domain"
@@ -100,7 +96,7 @@ func (f *removeOnCloseFile) Close() error {
100 96
101 // NewRuntime creates a new Docker runtime instance.97 // NewRuntime creates a new Docker runtime instance.
102 func NewRuntime() (*Runtime, error) {98 func NewRuntime() (*Runtime, error) {
103- cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())99+ cli, err := client.New(client.FromEnv)
104 if err != nil {100 if err != nil {
105 return nil, fmt.Errorf("failed to create Docker client: %w", err)101 return nil, fmt.Errorf("failed to create Docker client: %w", err)
106 }102 }
@@ -113,10 +109,7 @@ func NewRuntime() (*Runtime, error) {
113 // NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path.109 // NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path.
114 func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {110 func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
115 host := "unix://" + socketPath111 host := "unix://" + socketPath
116- cli, err := client.NewClientWithOpts(112+ cli, err := client.New(client.WithHost(host))
117- client.WithHost(host),
118- client.WithAPIVersionNegotiation(),
119- )
120 if err != nil {113 if err != nil {
121 return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)114 return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
122 }115 }
@@ -170,13 +163,13 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
170 if config.PidsLimit > 0 {163 if config.PidsLimit > 0 {
171 resources.PidsLimit = &config.PidsLimit164 resources.PidsLimit = &config.PidsLimit
172 }165 }
173- capDrop := strslice.StrSlice{"ALL"}166+ capDrop := []string{"ALL"}
174- capAdd := strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}167+ capAdd := []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
175 if config.CapDrop != nil {168 if config.CapDrop != nil {
176- capDrop = strslice.StrSlice(config.CapDrop)169+ capDrop = config.CapDrop
177 }170 }
178 if config.CapAdd != nil {171 if config.CapAdd != nil {
179- capAdd = strslice.StrSlice(config.CapAdd)172+ capAdd = config.CapAdd
180 }173 }
181 hostConfig := &container.HostConfig{174 hostConfig := &container.HostConfig{
182 PortBindings: portBindings,175 PortBindings: portBindings,
@@ -208,8 +201,13 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
208 }201 }
209 }202 }
210 203
211- // Create the container204+ // Create the container.
212- resp, err := r.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)205+ resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
206+ Config: containerConfig,
207+ HostConfig: hostConfig,
208+ NetworkingConfig: networkConfig,
209+ Name: config.Name,
210+ })
213 if err != nil {211 if err != nil {
214 return nil, log.WrapErr(err, "failed to create container")212 return nil, log.WrapErr(err, "failed to create container")
215 }213 }
@@ -220,38 +218,54 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
220 return r.InspectContainer(ctx, resp.ID)218 return r.InspectContainer(ctx, resp.ID)
221 }219 }
222 220
223-func buildPortBindings(config *domain.ContainerConfig) (nat.PortSet, nat.PortMap, error) {221+func buildPortBindings(config *domain.ContainerConfig) (network.PortSet, network.PortMap, error) {
224- exposedPorts := make(nat.PortSet)222+ exposedPorts := make(network.PortSet)
225- portBindings := make(nat.PortMap)223+ portBindings := make(network.PortMap)
226- addLegacyPortBindings(config.Ports, exposedPorts, portBindings)224+ if err := addLegacyPortBindings(config.Ports, exposedPorts, portBindings); err != nil {
225+ return nil, nil, err
226+ }
227 if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil {227 if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil {
228 return nil, nil, err228 return nil, nil, err
229 }229 }
230 return exposedPorts, portBindings, nil230 return exposedPorts, portBindings, nil
231 }231 }
232 232
233-func addLegacyPortBindings(ports []int, exposedPorts nat.PortSet, portBindings nat.PortMap) {233+func addLegacyPortBindings(ports []int, exposedPorts network.PortSet, portBindings network.PortMap) error {
234 for _, port := range ports {234 for _, port := range ports {
235- containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))235+ containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", port))
236+ if err != nil {
237+ return fmt.Errorf("invalid container port %d: %w", port, err)
238+ }
236 exposedPorts[containerPort] = struct{}{}239 exposedPorts[containerPort] = struct{}{}
237 240
238 // Bind to random available port on localhost only.241 // Bind to random available port on localhost only.
239 // SECURITY: Using 127.0.0.1 prevents direct access from the network,242 // SECURITY: Using 127.0.0.1 prevents direct access from the network,
240 // forcing all traffic through Gordon's reverse proxy where auth,243 // forcing all traffic through Gordon's reverse proxy where auth,
241 // rate limiting, and security headers are applied.244 // rate limiting, and security headers are applied.
242- portBindings[containerPort] = []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: "0"}}245+ portBindings[containerPort] = []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "0"}}
243 }246 }
247+ return nil
244 }248 }
245 249
246-func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts nat.PortSet, portBindings nat.PortMap) error {250+func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts network.PortSet, portBindings network.PortMap) error {
247 for _, publish := range publishes {251 for _, publish := range publishes {
248 if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {252 if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {
249 return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)253 return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)
250 }254 }
251- containerPort := nat.Port(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))255+ containerPort, err := network.ParsePort(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
256+ if err != nil {
257+ return fmt.Errorf("invalid container port %d: %w", publish.ContainerPort, err)
258+ }
259+ var hostIP netip.Addr
260+ if publish.HostIP != "" {
261+ hostIP, err = netip.ParseAddr(publish.HostIP)
262+ if err != nil {
263+ return fmt.Errorf("invalid host IP %q: %w", publish.HostIP, err)
264+ }
265+ }
252 exposedPorts[containerPort] = struct{}{}266 exposedPorts[containerPort] = struct{}{}
253- portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{267+ portBindings[containerPort] = append(portBindings[containerPort], network.PortBinding{
254- HostIP: publish.HostIP,268+ HostIP: hostIP,
255 HostPort: strconv.Itoa(publish.HostPort),269 HostPort: strconv.Itoa(publish.HostPort),
256 })270 })
257 }271 }
@@ -283,7 +297,7 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error
283 })297 })
284 log := zerowrap.FromCtx(ctx)298 log := zerowrap.FromCtx(ctx)
285 299
286- err := r.client.ContainerStart(ctx, containerID, container.StartOptions{})300+ _, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
287 if err != nil {301 if err != nil {
288 return log.WrapErr(err, "failed to start container")302 return log.WrapErr(err, "failed to start container")
289 }303 }
@@ -302,7 +316,8 @@ func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) erro
302 })316 })
303 log := zerowrap.FromCtx(ctx)317 log := zerowrap.FromCtx(ctx)
304 318
305- statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)319+ waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
320+ statusCh, errCh := waitResult.Result, waitResult.Error
306 select {321 select {
307 case err := <-errCh:322 case err := <-errCh:
308 if err != nil {323 if err != nil {
@@ -326,7 +341,7 @@ func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
326 log := zerowrap.FromCtx(ctx)341 log := zerowrap.FromCtx(ctx)
327 342
328 timeout := 20 // 20 seconds before SIGKILL343 timeout := 20 // 20 seconds before SIGKILL
329- err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})344+ _, err := r.client.ContainerStop(ctx, containerID, client.ContainerStopOptions{Timeout: &timeout})
330 if err != nil {345 if err != nil {
331 return log.WrapErr(err, "failed to stop container")346 return log.WrapErr(err, "failed to stop container")
332 }347 }
@@ -346,7 +361,7 @@ func (r *Runtime) RestartContainer(ctx context.Context, containerID string) erro
346 log := zerowrap.FromCtx(ctx)361 log := zerowrap.FromCtx(ctx)
347 362
348 timeout := 20 // 20 seconds before SIGKILL363 timeout := 20 // 20 seconds before SIGKILL
349- err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})364+ _, err := r.client.ContainerRestart(ctx, containerID, client.ContainerRestartOptions{Timeout: &timeout})
350 if err != nil {365 if err != nil {
351 return log.WrapErr(err, "failed to restart container")366 return log.WrapErr(err, "failed to restart container")
352 }367 }
@@ -366,7 +381,7 @@ func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force
366 })381 })
367 log := zerowrap.FromCtx(ctx)382 log := zerowrap.FromCtx(ctx)
368 383
369- err := r.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})384+ _, err := r.client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: force})
370 if err != nil {385 if err != nil {
371 return log.WrapErr(err, "failed to remove container")386 return log.WrapErr(err, "failed to remove container")
372 }387 }
@@ -386,7 +401,7 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri
386 })401 })
387 log := zerowrap.FromCtx(ctx)402 log := zerowrap.FromCtx(ctx)
388 403
389- err := r.client.ContainerRename(ctx, containerID, newName)404+ _, err := r.client.ContainerRename(ctx, containerID, client.ContainerRenameOptions{NewName: newName})
390 if err != nil {405 if err != nil {
391 return log.WrapErr(err, "failed to rename container")406 return log.WrapErr(err, "failed to rename container")
392 }407 }
@@ -405,13 +420,13 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
405 })420 })
406 log := zerowrap.FromCtx(ctx)421 log := zerowrap.FromCtx(ctx)
407 422
408- containers, err := r.client.ContainerList(ctx, container.ListOptions{All: all})423+ containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: all})
409 if err != nil {424 if err != nil {
410 return nil, log.WrapErr(err, "failed to list containers")425 return nil, log.WrapErr(err, "failed to list containers")
411 }426 }
412 427
413 var result []*domain.Container428 var result []*domain.Container
414- for _, c := range containers {429+ for _, c := range containerList.Items {
415 // Extract ports430 // Extract ports
416 var ports []int431 var ports []int
417 for _, port := range c.Ports {432 for _, port := range c.Ports {
@@ -442,7 +457,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
442 Image: c.Image,457 Image: c.Image,
443 ImageID: c.ImageID,458 ImageID: c.ImageID,
444 Name: name,459 Name: name,
445- Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")460+ Status: string(c.State), // Use State (e.g., "running") not Status (e.g., "Up 2 days")
446 Ports: ports,461 Ports: ports,
447 Labels: c.Labels,462 Labels: c.Labels,
448 VolumeMounts: volumeMounts,463 VolumeMounts: volumeMounts,
@@ -463,10 +478,11 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
463 })478 })
464 log := zerowrap.FromCtx(ctx)479 log := zerowrap.FromCtx(ctx)
465 480
466- resp, err := r.client.ContainerInspect(ctx, containerID)481+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
467 if err != nil {482 if err != nil {
468 return nil, log.WrapErr(err, "failed to inspect container")483 return nil, log.WrapErr(err, "failed to inspect container")
469 }484 }
485+ resp := inspectResult.Container
470 486
471 // Extract published ports487 // Extract published ports
472 var ports []int488 var ports []int
@@ -502,7 +518,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
502 Image: resp.Config.Image,518 Image: resp.Config.Image,
503 ImageID: resp.Image,519 ImageID: resp.Image,
504 Name: name,520 Name: name,
505- Status: resp.State.Status,521+ Status: string(resp.State.Status),
506 ExitCode: resp.State.ExitCode,522 ExitCode: resp.State.ExitCode,
507 Ports: ports,523 Ports: ports,
508 Labels: resp.Config.Labels,524 Labels: resp.Config.Labels,
@@ -521,7 +537,7 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll
521 })537 })
522 log := zerowrap.FromCtx(ctx)538 log := zerowrap.FromCtx(ctx)
523 539
524- logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{540+ logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{
525 ShowStdout: true,541 ShowStdout: true,
526 ShowStderr: true,542 ShowStderr: true,
527 Follow: follow,543 Follow: follow,
@@ -547,7 +563,7 @@ func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
547 563
548 log.Info().Msg("pulling image")564 log.Info().Msg("pulling image")
549 565
550- reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{})566+ reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{})
551 if err != nil {567 if err != nil {
552 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")568 return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
553 }569 }
@@ -605,7 +621,7 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
605 Msg("auth config for pull")621 Msg("auth config for pull")
606 622
607 // Pull with authentication623 // Pull with authentication
608- reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{624+ reader, err := r.client.ImagePull(ctx, imageRef, client.ImagePullOptions{
609 RegistryAuth: authStr,625 RegistryAuth: authStr,
610 })626 })
611 if err != nil {627 if err != nil {
@@ -634,7 +650,7 @@ func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) err
634 })650 })
635 log := zerowrap.FromCtx(ctx)651 log := zerowrap.FromCtx(ctx)
636 652
637- if err := r.client.ImageTag(ctx, sourceRef, targetRef); err != nil {653+ if _, err := r.client.ImageTag(ctx, client.ImageTagOptions{Source: sourceRef, Target: targetRef}); err != nil {
638 return log.WrapErr(err, "failed to tag image")654 return log.WrapErr(err, "failed to tag image")
639 }655 }
640 656
@@ -653,7 +669,7 @@ func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error {
653 log := zerowrap.FromCtx(ctx)669 log := zerowrap.FromCtx(ctx)
654 670
655 // ImageRemove with PruneChildren=false only removes the tag, not the image layers671 // ImageRemove with PruneChildren=false only removes the tag, not the image layers
656- _, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{672+ _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{
657 Force: false,673 Force: false,
658 PruneChildren: false,674 PruneChildren: false,
659 })675 })
@@ -676,7 +692,7 @@ func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool)
676 })692 })
677 log := zerowrap.FromCtx(ctx)693 log := zerowrap.FromCtx(ctx)
678 694
679- _, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})695+ _, err := r.client.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{Force: force})
680 if err != nil {696 if err != nil {
681 return log.WrapErr(err, "failed to remove image")697 return log.WrapErr(err, "failed to remove image")
682 }698 }
@@ -694,13 +710,13 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
694 })710 })
695 log := zerowrap.FromCtx(ctx)711 log := zerowrap.FromCtx(ctx)
696 712
697- images, err := r.client.ImageList(ctx, image.ListOptions{})713+ imageList, err := r.client.ImageList(ctx, client.ImageListOptions{})
698 if err != nil {714 if err != nil {
699 return nil, log.WrapErr(err, "failed to list images")715 return nil, log.WrapErr(err, "failed to list images")
700 }716 }
701 717
702 var result []string718 var result []string
703- for _, img := range images {719+ for _, img := range imageList.Items {
704 for _, tag := range img.RepoTags {720 for _, tag := range img.RepoTags {
705 if tag != "<none>:<none>" {721 if tag != "<none>:<none>" {
706 result = append(result, tag)722 result = append(result, tag)
@@ -720,13 +736,13 @@ func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDet
720 })736 })
721 log := zerowrap.FromCtx(ctx)737 log := zerowrap.FromCtx(ctx)
722 738
723- images, err := r.client.ImageList(ctx, image.ListOptions{All: true})739+ imageList, err := r.client.ImageList(ctx, client.ImageListOptions{All: true})
724 if err != nil {740 if err != nil {
725 return nil, log.WrapErr(err, "failed to list detailed images")741 return nil, log.WrapErr(err, "failed to list detailed images")
726 }742 }
727 743
728- result := make([]runtimepkg.ImageDetail, 0, len(images))744+ result := make([]runtimepkg.ImageDetail, 0, len(imageList.Items))
729- for _, img := range images {745+ for _, img := range imageList.Items {
730 result = append(result, runtimepkg.ImageDetail{746 result = append(result, runtimepkg.ImageDetail{
731 ID: img.ID,747 ID: img.ID,
732 RepoTags: img.RepoTags,748 RepoTags: img.RepoTags,
@@ -748,19 +764,18 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk
748 })764 })
749 log := zerowrap.FromCtx(ctx)765 log := zerowrap.FromCtx(ctx)
750 766
751- pruneFilters := filters.NewArgs()767+ pruneFilters := make(client.Filters).Add("label", domain.LabelManaged+"=true")
752- pruneFilters.Add("label", domain.LabelManaged+"=true")
753 if danglingOnly {768 if danglingOnly {
754 pruneFilters.Add("dangling", "true")769 pruneFilters.Add("dangling", "true")
755 }770 }
756 771
757- pruneResult, err := r.client.ImagesPrune(ctx, pruneFilters)772+ pruneResult, err := r.client.ImagePrune(ctx, client.ImagePruneOptions{Filters: pruneFilters})
758 if err != nil {773 if err != nil {
759 return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images")774 return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images")
760 }775 }
761 776
762- deletedIDs := make([]string, 0, len(pruneResult.ImagesDeleted))777+ deletedIDs := make([]string, 0, len(pruneResult.Report.ImagesDeleted))
763- for _, deleted := range pruneResult.ImagesDeleted {778+ for _, deleted := range pruneResult.Report.ImagesDeleted {
764 if deleted.Deleted != "" {779 if deleted.Deleted != "" {
765 deletedIDs = append(deletedIDs, deleted.Deleted)780 deletedIDs = append(deletedIDs, deleted.Deleted)
766 }781 }
@@ -769,7 +784,7 @@ func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepk
769 }784 }
770 }785 }
771 786
772- spaceReclaimed := pruneResult.SpaceReclaimed787+ spaceReclaimed := pruneResult.Report.SpaceReclaimed
773 if spaceReclaimed > math.MaxInt64 {788 if spaceReclaimed > math.MaxInt64 {
774 log.Warn().789 log.Warn().
775 Uint64("space_reclaimed_bytes", spaceReclaimed).790 Uint64("space_reclaimed_bytes", spaceReclaimed).
@@ -796,7 +811,7 @@ func (r *Runtime) Ping(ctx context.Context) error {
796 })811 })
797 log := zerowrap.FromCtx(ctx)812 log := zerowrap.FromCtx(ctx)
798 813
799- _, err := r.client.Ping(ctx)814+ _, err := r.client.Ping(ctx, client.PingOptions{})
800 if err != nil {815 if err != nil {
801 return log.WrapErr(err, "Docker ping failed")816 return log.WrapErr(err, "Docker ping failed")
802 }817 }
@@ -812,7 +827,7 @@ func (r *Runtime) Version(ctx context.Context) (string, error) {
812 })827 })
813 log := zerowrap.FromCtx(ctx)828 log := zerowrap.FromCtx(ctx)
814 829
815- version, err := r.client.ServerVersion(ctx)830+ version, err := r.client.ServerVersion(ctx, client.ServerVersionOptions{})
816 if err != nil {831 if err != nil {
817 return "", log.WrapErr(err, "failed to get Docker version")832 return "", log.WrapErr(err, "failed to get Docker version")
818 }833 }
@@ -838,10 +853,11 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri
838 })853 })
839 log := zerowrap.FromCtx(ctx)854 log := zerowrap.FromCtx(ctx)
840 855
841- resp, err := r.client.ContainerInspect(ctx, containerID)856+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
842 if err != nil {857 if err != nil {
843 return "", false, log.WrapErr(err, "failed to inspect container")858 return "", false, log.WrapErr(err, "failed to inspect container")
844 }859 }
860+ resp := inspectResult.Container
845 861
846 // Detect configured healthchecks from container config rather than relying on862 // Detect configured healthchecks from container config rather than relying on
847 // State.Health presence. Some runtimes can expose an empty State.Health struct863 // State.Health presence. Some runtimes can expose an empty State.Health struct
@@ -854,7 +870,7 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri
854 return "", true, nil870 return "", true, nil
855 }871 }
856 872
857- return resp.State.Health.Status, true, nil873+ return string(resp.State.Health.Status), true, nil
858 }874 }
859 875
860 func hasConfiguredHealthcheck(cfg *container.Config) bool {876 func hasConfiguredHealthcheck(cfg *container.Config) bool {
@@ -876,16 +892,20 @@ func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, inte
876 })892 })
877 log := zerowrap.FromCtx(ctx)893 log := zerowrap.FromCtx(ctx)
878 894
879- resp, err := r.client.ContainerInspect(ctx, containerID)895+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
880 if err != nil {896 if err != nil {
881 return 0, log.WrapErr(err, "failed to inspect container")897 return 0, log.WrapErr(err, "failed to inspect container")
882 }898 }
899+ resp := inspectResult.Container
883 900
884 if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {901 if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
885 return 0, fmt.Errorf("no port mappings found for container %s", containerID)902 return 0, fmt.Errorf("no port mappings found for container %s", containerID)
886 }903 }
887 904
888- containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))905+ containerPort, err := network.ParsePort(fmt.Sprintf("%d/tcp", internalPort))
906+ if err != nil {
907+ return 0, fmt.Errorf("invalid container port %d: %w", internalPort, err)
908+ }
889 bindings, exists := resp.NetworkSettings.Ports[containerPort]909 bindings, exists := resp.NetworkSettings.Ports[containerPort]
890 if !exists || len(bindings) == 0 {910 if !exists || len(bindings) == 0 {
891 return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)911 return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
@@ -975,16 +995,17 @@ func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID stri
975 })995 })
976 log := zerowrap.FromCtx(ctx)996 log := zerowrap.FromCtx(ctx)
977 997
978- resp, err := r.client.ContainerInspect(ctx, containerID)998+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
979 if err != nil {999 if err != nil {
980 return nil, log.WrapErr(err, "failed to inspect container")1000 return nil, log.WrapErr(err, "failed to inspect container")
981 }1001 }
1002+ resp := inspectResult.Container
982 1003
983 var ports []int1004 var ports []int
984 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {1005 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
985 for portSpec := range resp.NetworkSettings.Ports {1006 for portSpec := range resp.NetworkSettings.Ports {
986- // Parse port from format like "80/tcp"1007+ // Parse port from format like "80/tcp".
987- portStr := strings.Split(string(portSpec), "/")[0]1008+ portStr := portSpec.Port()
988 if port, err := strconv.Atoi(portStr); err == nil {1009 if port, err := strconv.Atoi(portStr); err == nil {
989 ports = append(ports, port)1010 ports = append(ports, port)
990 }1011 }
@@ -1004,18 +1025,19 @@ func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID strin
1004 })1025 })
1005 log := zerowrap.FromCtx(ctx)1026 log := zerowrap.FromCtx(ctx)
1006 1027
1007- resp, err := r.client.ContainerInspect(ctx, containerID)1028+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1008 if err != nil {1029 if err != nil {
1009 return "", 0, log.WrapErr(err, "failed to inspect container")1030 return "", 0, log.WrapErr(err, "failed to inspect container")
1010 }1031 }
1032+ resp := inspectResult.Container
1011 1033
1012 // Get container's internal IP address1034 // Get container's internal IP address
1013 var containerIP string1035 var containerIP string
1014 if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {1036 if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
1015 // Use the first available network (usually bridge or custom network)1037 // Use the first available network (usually bridge or custom network)
1016 for _, net := range resp.NetworkSettings.Networks {1038 for _, net := range resp.NetworkSettings.Networks {
1017- if net.IPAddress != "" {1039+ if net.IPAddress.IsValid() {
1018- containerIP = net.IPAddress1040+ containerIP = net.IPAddress.String()
1019 break1041 break
1020 }1042 }
1021 }1043 }
@@ -1029,9 +1051,8 @@ func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID strin
1029 var exposedPorts []int1051 var exposedPorts []int
1030 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {1052 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
1031 for portSpec := range resp.NetworkSettings.Ports {1053 for portSpec := range resp.NetworkSettings.Ports {
1032- // Parse port from format like "80/tcp"1054+ // Parse port from format like "80/tcp".
1033- portStr := strings.Split(string(portSpec), "/")[0]1055+ if port, err := strconv.Atoi(portSpec.Port()); err == nil {
1034- if port, err := strconv.Atoi(portStr); err == nil {
1035 exposedPorts = append(exposedPorts, port)1056 exposedPorts = append(exposedPorts, port)
1036 }1057 }
1037 }1058 }
@@ -1064,12 +1085,12 @@ func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (
1064 })1085 })
1065 log := zerowrap.FromCtx(ctx)1086 log := zerowrap.FromCtx(ctx)
1066 1087
1067- resp, err := r.client.ContainerInspect(ctx, containerID)1088+ inspectResult, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
1068 if err != nil {1089 if err != nil {
1069 return "", log.WrapErr(err, "failed to inspect container")1090 return "", log.WrapErr(err, "failed to inspect container")
1070 }1091 }
1071 1092
1072- networks := resp.NetworkSettings1093+ networks := inspectResult.Container.NetworkSettings
1073 if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {1094 if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 {
1074 return "bridge", nil1095 return "bridge", nil
1075 }1096 }
@@ -1145,7 +1166,7 @@ func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, er
1145 })1166 })
1146 log := zerowrap.FromCtx(ctx)1167 log := zerowrap.FromCtx(ctx)
1147 1168
1148- _, err := r.client.VolumeInspect(ctx, volumeName)1169+ _, err := r.client.VolumeInspect(ctx, volumeName, client.VolumeInspectOptions{})
1149 if err != nil {1170 if err != nil {
1150 if cerrdefs.IsNotFound(err) {1171 if cerrdefs.IsNotFound(err) {
1151 return false, nil1172 return false, nil
@@ -1165,7 +1186,7 @@ func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error {
1165 })1186 })
1166 log := zerowrap.FromCtx(ctx)1187 log := zerowrap.FromCtx(ctx)
1167 1188
1168- _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{1189+ _, err := r.client.VolumeCreate(ctx, client.VolumeCreateOptions{
1169 Name: volumeName,1190 Name: volumeName,
1170 Labels: map[string]string{1191 Labels: map[string]string{
1171 domain.LabelManaged: "true",1192 domain.LabelManaged: "true",
@@ -1191,7 +1212,7 @@ func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force boo
1191 })1212 })
1192 log := zerowrap.FromCtx(ctx)1213 log := zerowrap.FromCtx(ctx)
1193 1214
1194- err := r.client.VolumeRemove(ctx, volumeName, force)1215+ _, err := r.client.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
1195 if err != nil {1216 if err != nil {
1196 if cerrdefs.IsNotFound(err) {1217 if cerrdefs.IsNotFound(err) {
1197 log.Debug().Msg("volume not found, already removed")1218 log.Debug().Msg("volume not found, already removed")
@@ -1206,20 +1227,27 @@ func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force boo
1206 1227
1207 // ListVolumes returns all named volumes with their usage status.1228 // ListVolumes returns all named volumes with their usage status.
1208 func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {1229 func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1209- volumeList, err := r.client.VolumeList(ctx, volume.ListOptions{})1230+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1231+ zerowrap.FieldLayer: "adapter",
1232+ zerowrap.FieldAdapter: "docker",
1233+ zerowrap.FieldAction: "ListVolumes",
1234+ })
1235+ log := zerowrap.FromCtx(ctx)
1236+
1237+ volumeList, err := r.client.VolumeList(ctx, client.VolumeListOptions{})
1210 if err != nil {1238 if err != nil {
1211- return nil, fmt.Errorf("failed to list volumes: %w", err)1239+ return nil, log.WrapErr(err, "failed to list volumes")
1212 }1240 }
1213 1241
1214- // Get all containers to determine volume usage1242+ // Get all containers to determine volume usage.
1215- containers, err := r.client.ContainerList(ctx, container.ListOptions{All: true})1243+ containerList, err := r.client.ContainerList(ctx, client.ContainerListOptions{All: true})
1216 if err != nil {1244 if err != nil {
1217- return nil, fmt.Errorf("failed to list containers for volume usage: %w", err)1245+ return nil, log.WrapErr(err, "failed to list containers for volume usage")
1218 }1246 }
1219 1247
1220- // Build a map of volume name -> container names1248+ // Build a map of volume name -> container names.
1221 volumeUsers := make(map[string][]string)1249 volumeUsers := make(map[string][]string)
1222- for _, c := range containers {1250+ for _, c := range containerList.Items {
1223 for _, m := range c.Mounts {1251 for _, m := range c.Mounts {
1224 if m.Type == "volume" {1252 if m.Type == "volume" {
1225 name := ""1253 name := ""
@@ -1232,11 +1260,7 @@ func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
1232 }1260 }
1233 1261
1234 var result []*domain.VolumeInfo1262 var result []*domain.VolumeInfo
1235- for _, v := range volumeList.Volumes {1263+ for _, v := range volumeList.Items {
1236- if v == nil {
1237- continue
1238- }
1239-
1240 var size int641264 var size int64
1241 if v.UsageData != nil {1265 if v.UsageData != nil {
1242 size = v.UsageData.Size1266 size = v.UsageData.Size
@@ -1281,7 +1305,7 @@ func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArch
1281 if err != nil {1305 if err != nil {
1282 return nil, err1306 return nil, err
1283 }1307 }
1284- if err := r.client.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {1308+ if _, err := r.client.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
1285 _ = cleanup()1309 _ = cleanup()
1286 return nil, log.WrapErr(err, "failed to start volume archive helper container")1310 return nil, log.WrapErr(err, "failed to start volume archive helper container")
1287 }1311 }
@@ -1314,14 +1338,18 @@ func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef s
1314 return nil1338 return nil
1315 }1339 }
1316 1340
1317-func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (container.CreateResponse, func() error, error) {1341+func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (client.ContainerCreateResult, func() error, error) {
1318 cmd, err := volumeArchiveCommand(req.Compression)1342 cmd, err := volumeArchiveCommand(req.Compression)
1319 if err != nil {1343 if err != nil {
1320- return container.CreateResponse{}, nil, err1344+ return client.ContainerCreateResult{}, nil, err
1321 }1345 }
1322- created, err := r.client.ContainerCreate(ctx, volumeArchiveContainerConfig(req.HelperImage, cmd), volumeArchiveHostConfig(req.VolumeName), nil, nil, fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()))1346+ created, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
1347+ Config: volumeArchiveContainerConfig(req.HelperImage, cmd),
1348+ HostConfig: volumeArchiveHostConfig(req.VolumeName),
1349+ Name: fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()),
1350+ })
1323 if err != nil {1351 if err != nil {
1324- return container.CreateResponse{}, nil, log.WrapErr(err, "failed to create volume archive helper container")1352+ return client.ContainerCreateResult{}, nil, log.WrapErr(err, "failed to create volume archive helper container")
1325 }1353 }
1326 var cleanupOnce sync.Once1354 var cleanupOnce sync.Once
1327 cleanup := func() error {1355 cleanup := func() error {
@@ -1363,11 +1391,12 @@ func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
1363 }1391 }
1364 1392
1365 func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {1393 func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1366- logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})1394+ logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
1367 if err != nil {1395 if err != nil {
1368 return nil, log.WrapErr(err, "failed to attach volume archive stream")1396 return nil, log.WrapErr(err, "failed to attach volume archive stream")
1369 }1397 }
1370- statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)1398+ waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
1399+ statusCh, errCh := waitResult.Result, waitResult.Error
1371 pr, pw := io.Pipe()1400 pr, pw := io.Pipe()
1372 go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)1401 go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1373 return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil1402 return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil
@@ -1538,7 +1567,7 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.
1538 maps.Copy(labels, config.Labels)1567 maps.Copy(labels, config.Labels)
1539 labels[domain.LabelManaged] = "true"1568 labels[domain.LabelManaged] = "true"
1540 1569
1541- createOptions := network.CreateOptions{1570+ createOptions := client.NetworkCreateOptions{
1542 Driver: driver,1571 Driver: driver,
1543 Internal: config.Internal,1572 Internal: config.Internal,
1544 Labels: labels,1573 Labels: labels,
@@ -1563,7 +1592,7 @@ func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error {
1563 })1592 })
1564 log := zerowrap.FromCtx(ctx)1593 log := zerowrap.FromCtx(ctx)
1565 1594
1566- err := r.client.NetworkRemove(ctx, name)1595+ _, err := r.client.NetworkRemove(ctx, name, client.NetworkRemoveOptions{})
1567 if err != nil {1596 if err != nil {
1568 if cerrdefs.IsNotFound(err) {1597 if cerrdefs.IsNotFound(err) {
1569 log.Debug().Msg("network not found, already removed")1598 log.Debug().Msg("network not found, already removed")
@@ -1585,15 +1614,19 @@ func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro
1585 })1614 })
1586 log := zerowrap.FromCtx(ctx)1615 log := zerowrap.FromCtx(ctx)
1587 1616
1588- networks, err := r.client.NetworkList(ctx, network.ListOptions{})1617+ networkList, err := r.client.NetworkList(ctx, client.NetworkListOptions{})
1589 if err != nil {1618 if err != nil {
1590 return nil, log.WrapErr(err, "failed to list networks")1619 return nil, log.WrapErr(err, "failed to list networks")
1591 }1620 }
1592 1621
1593 var result []*domain.NetworkInfo1622 var result []*domain.NetworkInfo
1594- for _, net := range networks {1623+ for _, net := range networkList.Items {
1624+ networkInspect, err := r.client.NetworkInspect(ctx, net.ID, client.NetworkInspectOptions{})
1625+ if err != nil {
1626+ return nil, log.WrapErr(err, "failed to inspect network")
1627+ }
1595 var containers []string1628 var containers []string
1596- for containerID := range net.Containers {1629+ for containerID := range networkInspect.Network.Containers {
1597 containers = append(containers, containerID)1630 containers = append(containers, containerID)
1598 }1631 }
1599 1632
@@ -1619,7 +1652,7 @@ func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error)
1619 })1652 })
1620 log := zerowrap.FromCtx(ctx)1653 log := zerowrap.FromCtx(ctx)
1621 1654
1622- _, err := r.client.NetworkInspect(ctx, name, network.InspectOptions{})1655+ _, err := r.client.NetworkInspect(ctx, name, client.NetworkInspectOptions{})
1623 if err != nil {1656 if err != nil {
1624 if cerrdefs.IsNotFound(err) {1657 if cerrdefs.IsNotFound(err) {
1625 return false, nil1658 return false, nil
@@ -1640,7 +1673,10 @@ func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName,
1640 })1673 })
1641 log := zerowrap.FromCtx(ctx)1674 log := zerowrap.FromCtx(ctx)
1642 1675
1643- err := r.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})1676+ _, err := r.client.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
1677+ Container: containerName,
1678+ EndpointConfig: &network.EndpointSettings{},
1679+ })
1644 if err != nil {1680 if err != nil {
1645 return log.WrapErr(err, "failed to connect container to network")1681 return log.WrapErr(err, "failed to connect container to network")
1646 }1682 }
@@ -1660,7 +1696,9 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN
1660 })1696 })
1661 log := zerowrap.FromCtx(ctx)1697 log := zerowrap.FromCtx(ctx)
1662 1698
1663- err := r.client.NetworkDisconnect(ctx, networkName, containerName, false)1699+ _, err := r.client.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{
1700+ Container: containerName,
1701+ })
1664 if err != nil {1702 if err != nil {
1665 return log.WrapErr(err, "failed to disconnect container from network")1703 return log.WrapErr(err, "failed to disconnect container from network")
1666 }1704 }
@@ -1685,7 +1723,7 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
1685 })1723 })
1686 log := zerowrap.FromCtx(ctx)1724 log := zerowrap.FromCtx(ctx)
1687 1725
1688- execResp, err := r.client.ContainerExecCreate(ctx, containerID, container.ExecOptions{1726+ execResp, err := r.client.ExecCreate(ctx, containerID, client.ExecCreateOptions{
1689 Cmd: cmd,1727 Cmd: cmd,
1690 AttachStdout: true,1728 AttachStdout: true,
1691 AttachStderr: true,1729 AttachStderr: true,
@@ -1701,7 +1739,7 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
1701 defer cancel()1739 defer cancel()
1702 }1740 }
1703 1741
1704- attachResp, err := r.client.ContainerExecAttach(attachCtx, execResp.ID, container.ExecAttachOptions{})1742+ attachResp, err := r.client.ExecAttach(attachCtx, execResp.ID, client.ExecAttachOptions{})
1705 if err != nil {1743 if err != nil {
1706 return nil, log.WrapErr(err, "failed to attach to exec")1744 return nil, log.WrapErr(err, "failed to attach to exec")
1707 }1745 }
@@ -1715,7 +1753,7 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
1715 return nil, log.WrapErr(err, "failed to read exec output")1753 return nil, log.WrapErr(err, "failed to read exec output")
1716 }1754 }
1717 1755
1718- inspectResp, err := r.client.ContainerExecInspect(ctx, execResp.ID)1756+ inspectResp, err := r.client.ExecInspect(ctx, execResp.ID, client.ExecInspectOptions{})
1719 if err != nil {1757 if err != nil {
1720 return nil, log.WrapErr(err, "failed to inspect exec result")1758 return nil, log.WrapErr(err, "failed to inspect exec result")
1721 }1759 }
@@ -1787,15 +1825,15 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
1787 })1825 })
1788 log := zerowrap.FromCtx(ctx)1826 log := zerowrap.FromCtx(ctx)
1789 1827
1790- tarReader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)1828+ copyResult, err := r.client.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{SourcePath: srcPath})
1791 if err != nil {1829 if err != nil {
1792 return nil, log.WrapErr(err, "failed to copy from container")1830 return nil, log.WrapErr(err, "failed to copy from container")
1793 }1831 }
1794 1832
1795 // The response is a tar archive - we need to extract the requested file stream.1833 // The response is a tar archive - we need to extract the requested file stream.
1796- reader, err := extractFileFromTar(tarReader, srcPath)1834+ reader, err := extractFileFromTar(copyResult.Content, srcPath)
1797 if err != nil {1835 if err != nil {
1798- _ = tarReader.Close()1836+ _ = copyResult.Content.Close()
1799 return nil, log.WrapErr(err, "failed to extract file from tar")1837 return nil, log.WrapErr(err, "failed to extract file from tar")
1800 }1838 }
1801 1839
@@ -1928,7 +1966,7 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
1928 Cmd: []string{"true"}, // Dummy command1966 Cmd: []string{"true"}, // Dummy command
1929 }1967 }
1930 1968
1931- resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "")1969+ resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{Config: containerConfig})
1932 if err != nil {1970 if err != nil {
1933 return nil, log.WrapErr(err, "failed to create temporary container")1971 return nil, log.WrapErr(err, "failed to create temporary container")
1934 }1972 }
@@ -1938,7 +1976,7 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
1938 1976
1939 // Ensure cleanup1977 // Ensure cleanup
1940 defer func() {1978 defer func() {
1941- if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil {1979+ if _, err := r.client.ContainerRemove(ctx, tempContainerID, client.ContainerRemoveOptions{Force: true}); err != nil {
1942 log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")1980 log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container")
1943 }1981 }
1944 }()1982 }()
M internal/adapters/out/docker/runtime_create_test.go +2 -2
@@ -8,7 +8,7 @@ import (
88 "strings"
99 "testing"
1010
11- "github.com/docker/docker/client"
11+ "github.com/moby/moby/client"
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/require"
1414
@@ -112,7 +112,7 @@ func createTestContainer(t *testing.T, config *domain.ContainerConfig) map[strin
112112 defer server.Close()
113113
114114 host := strings.TrimPrefix(server.URL, "http://")
115- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
115+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
116116 require.NoError(t, err)
117117
118118 runtime := NewRuntimeWithClient(cli)
@@ -8,7 +8,7 @@ import (
8 "strings"8 "strings"
9 "testing"9 "testing"
10 10
11- "github.com/docker/docker/client"11+ "github.com/moby/moby/client"
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
@@ -112,7 +112,7 @@ func createTestContainer(t *testing.T, config *domain.ContainerConfig) map[strin
112 defer server.Close()112 defer server.Close()
113 113
114 host := strings.TrimPrefix(server.URL, "http://")114 host := strings.TrimPrefix(server.URL, "http://")
115- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))115+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
116 require.NoError(t, err)116 require.NoError(t, err)
117 117
118 runtime := NewRuntimeWithClient(cli)118 runtime := NewRuntimeWithClient(cli)
M internal/adapters/out/docker/runtime_image_cleanup_test.go +9 -9
@@ -2,6 +2,7 @@ package docker
22
33 import (
44 "context"
5+ "encoding/json"
56 "math"
67 "net/http"
78 "net/http/httptest"
@@ -9,8 +10,7 @@ import (
910 "testing"
1011 "time"
1112
12- "github.com/docker/docker/api/types/filters"
13- "github.com/docker/docker/client"
13+ "github.com/moby/moby/client"
1414 "github.com/stretchr/testify/assert"
1515 "github.com/stretchr/testify/require"
1616 )
@@ -20,9 +20,9 @@ func TestRuntime_PruneImages_DanglingOnlyFilter(t *testing.T) {
2020 assert.Equal(t, http.MethodPost, r.Method)
2121 assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
2222
23- parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))
24- require.NoError(t, err)
25- assert.Equal(t, []string{"true"}, parsedFilters.Get("dangling"))
23+ parsedFilters := client.Filters{}
24+ require.NoError(t, json.Unmarshal([]byte(r.URL.Query().Get("filters")), &parsedFilters))
25+ assert.True(t, parsedFilters["dangling"]["true"])
2626
2727 w.Header().Set("Content-Type", "application/json")
2828 _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:abc"},{"Untagged":"example:old"}],"SpaceReclaimed":1234}`))
@@ -42,9 +42,9 @@ func TestRuntime_PruneImages_FullUnusedHasNoDanglingFilter(t *testing.T) {
4242 assert.Equal(t, http.MethodPost, r.Method)
4343 assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
4444
45- parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))
46- require.NoError(t, err)
47- assert.Empty(t, parsedFilters.Get("dangling"))
45+ parsedFilters := client.Filters{}
46+ require.NoError(t, json.Unmarshal([]byte(r.URL.Query().Get("filters")), &parsedFilters))
47+ assert.Empty(t, parsedFilters["dangling"])
4848
4949 w.Header().Set("Content-Type", "application/json")
5050 _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:def"}],"SpaceReclaimed":5678}`))
@@ -183,7 +183,7 @@ func newRuntimeForHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
183183 t.Helper()
184184
185185 host := strings.TrimPrefix(server.URL, "http://")
186- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
186+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
187187 require.NoError(t, err)
188188
189189 return NewRuntimeWithClient(cli)
@@ -2,6 +2,7 @@ package docker
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "math"6 "math"
6 "net/http"7 "net/http"
7 "net/http/httptest"8 "net/http/httptest"
@@ -9,8 +10,7 @@ import (
9 "testing"10 "testing"
10 "time"11 "time"
11 12
12- "github.com/docker/docker/api/types/filters"13+ "github.com/moby/moby/client"
13- "github.com/docker/docker/client"
14 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 )16 )
@@ -20,9 +20,9 @@ func TestRuntime_PruneImages_DanglingOnlyFilter(t *testing.T) {
20 assert.Equal(t, http.MethodPost, r.Method)20 assert.Equal(t, http.MethodPost, r.Method)
21 assert.Equal(t, "/v1.41/images/prune", r.URL.Path)21 assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
22 22
23- parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))23+ parsedFilters := client.Filters{}
24- require.NoError(t, err)24+ require.NoError(t, json.Unmarshal([]byte(r.URL.Query().Get("filters")), &parsedFilters))
25- assert.Equal(t, []string{"true"}, parsedFilters.Get("dangling"))25+ assert.True(t, parsedFilters["dangling"]["true"])
26 26
27 w.Header().Set("Content-Type", "application/json")27 w.Header().Set("Content-Type", "application/json")
28 _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:abc"},{"Untagged":"example:old"}],"SpaceReclaimed":1234}`))28 _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:abc"},{"Untagged":"example:old"}],"SpaceReclaimed":1234}`))
@@ -42,9 +42,9 @@ func TestRuntime_PruneImages_FullUnusedHasNoDanglingFilter(t *testing.T) {
42 assert.Equal(t, http.MethodPost, r.Method)42 assert.Equal(t, http.MethodPost, r.Method)
43 assert.Equal(t, "/v1.41/images/prune", r.URL.Path)43 assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
44 44
45- parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))45+ parsedFilters := client.Filters{}
46- require.NoError(t, err)46+ require.NoError(t, json.Unmarshal([]byte(r.URL.Query().Get("filters")), &parsedFilters))
47- assert.Empty(t, parsedFilters.Get("dangling"))47+ assert.Empty(t, parsedFilters["dangling"])
48 48
49 w.Header().Set("Content-Type", "application/json")49 w.Header().Set("Content-Type", "application/json")
50 _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:def"}],"SpaceReclaimed":5678}`))50 _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:def"}],"SpaceReclaimed":5678}`))
@@ -183,7 +183,7 @@ func newRuntimeForHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
183 t.Helper()183 t.Helper()
184 184
185 host := strings.TrimPrefix(server.URL, "http://")185 host := strings.TrimPrefix(server.URL, "http://")
186- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))186+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
187 require.NoError(t, err)187 require.NoError(t, err)
188 188
189 return NewRuntimeWithClient(cli)189 return NewRuntimeWithClient(cli)
M internal/adapters/out/docker/runtime_test.go +3 -3
@@ -10,8 +10,8 @@ import (
1010 "testing"
1111
1212 "github.com/bnema/zerowrap"
13- "github.com/docker/docker/api/types/container"
14- "github.com/docker/docker/client"
13+ "github.com/moby/moby/api/types/container"
14+ "github.com/moby/moby/client"
1515 "github.com/stretchr/testify/assert"
1616 "github.com/stretchr/testify/require"
1717 )
@@ -31,7 +31,7 @@ func TestRuntime_InspectImageEnv_RedactsValuesInDebugLog(t *testing.T) {
3131 defer server.Close()
3232
3333 host := strings.TrimPrefix(server.URL, "http://")
34- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
34+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
3535 require.NoError(t, err)
3636 runtime := NewRuntimeWithClient(cli)
3737
@@ -10,8 +10,8 @@ import (
10 "testing"10 "testing"
11 11
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13- "github.com/docker/docker/api/types/container"13+ "github.com/moby/moby/api/types/container"
14- "github.com/docker/docker/client"14+ "github.com/moby/moby/client"
15 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 )17 )
@@ -31,7 +31,7 @@ func TestRuntime_InspectImageEnv_RedactsValuesInDebugLog(t *testing.T) {
31 defer server.Close()31 defer server.Close()
32 32
33 host := strings.TrimPrefix(server.URL, "http://")33 host := strings.TrimPrefix(server.URL, "http://")
34- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))34+ cli, err := client.New(client.WithHost("tcp://"+host), client.WithAPIVersion("1.41"), client.WithHTTPClient(server.Client()))
35 require.NoError(t, err)35 require.NoError(t, err)
36 runtime := NewRuntimeWithClient(cli)36 runtime := NewRuntimeWithClient(cli)
37 37
M internal/adapters/out/filesystem/blob.go +38 -3
@@ -223,6 +223,23 @@ func (s *BlobStorage) ListBlobs() ([]string, error) {
223223 return digests, nil
224224 }
225225
226+// GetBlobModTime returns the last modification time for a blob.
227+func (s *BlobStorage) GetBlobModTime(digest string) (time.Time, error) {
228+ blobPath, err := s.getBlobPath(digest)
229+ if err != nil {
230+ return time.Time{}, fmt.Errorf("invalid blob path: %w", err)
231+ }
232+
233+ info, err := os.Stat(blobPath)
234+ if err != nil {
235+ if os.IsNotExist(err) {
236+ return time.Time{}, fmt.Errorf("blob not found: %s", digest)
237+ }
238+ return time.Time{}, fmt.Errorf("failed to stat blob: %w", err)
239+ }
240+ return info.ModTime(), nil
241+}
242+
226243 // StartBlobUpload starts a new blob upload and returns the upload UUID.
227244 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
228245 uploadID := uuid.New().String()
@@ -394,7 +411,14 @@ func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
394411 return fmt.Errorf("failed to create blob directory: %w", err)
395412 }
396413
397- // Move upload file to blob location
414+ // Update the timestamp before rename so it survives a restart even when the
415+ // upload has been open longer than the pending-blob grace period.
416+ now := time.Now()
417+ if err := os.Chtimes(uploadPath, now, now); err != nil {
418+ return fmt.Errorf("failed to mark blob as finalized: %w", err)
419+ }
420+
421+ // Move upload file to blob location.
398422 if err := os.Rename(uploadPath, blobPath); err != nil {
399423 return fmt.Errorf("failed to move upload to blob location: %w", err)
400424 }
@@ -474,11 +498,22 @@ func (s *BlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, err
474498
475499 if info.ModTime().Before(cutoff) {
476500 path := filepath.Join(uploadsDir, entry.Name())
477- size := info.Size()
478501
479502 mu := s.getUploadLock(entry.Name())
480503 mu.Lock()
481-
504+ currentInfo, err := os.Stat(path)
505+ if err != nil {
506+ mu.Unlock()
507+ if !os.IsNotExist(err) {
508+ s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to stat stale upload")
509+ }
510+ continue
511+ }
512+ if !currentInfo.ModTime().Before(cutoff) {
513+ mu.Unlock()
514+ continue
515+ }
516+ size := currentInfo.Size()
482517 if err := os.Remove(path); err != nil {
483518 mu.Unlock()
484519 s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to remove stale upload")
@@ -223,6 +223,23 @@ func (s *BlobStorage) ListBlobs() ([]string, error) {
223 return digests, nil223 return digests, nil
224 }224 }
225 225
226+// GetBlobModTime returns the last modification time for a blob.
227+func (s *BlobStorage) GetBlobModTime(digest string) (time.Time, error) {
228+ blobPath, err := s.getBlobPath(digest)
229+ if err != nil {
230+ return time.Time{}, fmt.Errorf("invalid blob path: %w", err)
231+ }
232+
233+ info, err := os.Stat(blobPath)
234+ if err != nil {
235+ if os.IsNotExist(err) {
236+ return time.Time{}, fmt.Errorf("blob not found: %s", digest)
237+ }
238+ return time.Time{}, fmt.Errorf("failed to stat blob: %w", err)
239+ }
240+ return info.ModTime(), nil
241+}
242+
226 // StartBlobUpload starts a new blob upload and returns the upload UUID.243 // StartBlobUpload starts a new blob upload and returns the upload UUID.
227 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {244 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
228 uploadID := uuid.New().String()245 uploadID := uuid.New().String()
@@ -394,7 +411,14 @@ func (s *BlobStorage) FinishBlobUpload(uuid, digest string) error {
394 return fmt.Errorf("failed to create blob directory: %w", err)411 return fmt.Errorf("failed to create blob directory: %w", err)
395 }412 }
396 413
397- // Move upload file to blob location414+ // Update the timestamp before rename so it survives a restart even when the
415+ // upload has been open longer than the pending-blob grace period.
416+ now := time.Now()
417+ if err := os.Chtimes(uploadPath, now, now); err != nil {
418+ return fmt.Errorf("failed to mark blob as finalized: %w", err)
419+ }
420+
421+ // Move upload file to blob location.
398 if err := os.Rename(uploadPath, blobPath); err != nil {422 if err := os.Rename(uploadPath, blobPath); err != nil {
399 return fmt.Errorf("failed to move upload to blob location: %w", err)423 return fmt.Errorf("failed to move upload to blob location: %w", err)
400 }424 }
@@ -474,11 +498,22 @@ func (s *BlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, err
474 498
475 if info.ModTime().Before(cutoff) {499 if info.ModTime().Before(cutoff) {
476 path := filepath.Join(uploadsDir, entry.Name())500 path := filepath.Join(uploadsDir, entry.Name())
477- size := info.Size()
478 501
479 mu := s.getUploadLock(entry.Name())502 mu := s.getUploadLock(entry.Name())
480 mu.Lock()503 mu.Lock()
481-504+ currentInfo, err := os.Stat(path)
505+ if err != nil {
506+ mu.Unlock()
507+ if !os.IsNotExist(err) {
508+ s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to stat stale upload")
509+ }
510+ continue
511+ }
512+ if !currentInfo.ModTime().Before(cutoff) {
513+ mu.Unlock()
514+ continue
515+ }
516+ size := currentInfo.Size()
482 if err := os.Remove(path); err != nil {517 if err := os.Remove(path); err != nil {
483 mu.Unlock()518 mu.Unlock()
484 s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to remove stale upload")519 s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to remove stale upload")
M internal/adapters/out/filesystem/blob_test.go +22 -0
@@ -10,6 +10,7 @@ import (
1010 "path/filepath"
1111 "sync"
1212 "testing"
13+ "time"
1314
1415 "github.com/bnema/zerowrap"
1516 "github.com/stretchr/testify/assert"
@@ -680,6 +681,27 @@ func TestBlobStorage_AppendAfterFinalize(t *testing.T) {
680681 assert.Contains(t, err.Error(), "upload not found")
681682 }
682683
684+func TestBlobStorage_FinishBlobUploadMarksBlobAsRecentlyFinalized(t *testing.T) {
685+ tmpDir := t.TempDir()
686+ storage, err := NewBlobStorage(tmpDir, testLogger())
687+ require.NoError(t, err)
688+
689+ uuid, err := storage.StartBlobUpload("myapp")
690+ require.NoError(t, err)
691+ blobData := []byte("finalized content")
692+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
693+ require.NoError(t, err)
694+
695+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
696+ old := time.Now().Add(-2 * 24 * time.Hour)
697+ require.NoError(t, os.Chtimes(uploadPath, old, old))
698+
699+ require.NoError(t, storage.FinishBlobUpload(uuid, digestForData(blobData)))
700+ modTime, err := storage.GetBlobModTime(digestForData(blobData))
701+ require.NoError(t, err)
702+ assert.WithinDuration(t, time.Now(), modTime, time.Minute)
703+}
704+
683705 func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {
684706 tmpDir := t.TempDir()
685707 log := testLogger()
@@ -10,6 +10,7 @@ import (
10 "path/filepath"10 "path/filepath"
11 "sync"11 "sync"
12 "testing"12 "testing"
13+ "time"
13 14
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
15 "github.com/stretchr/testify/assert"16 "github.com/stretchr/testify/assert"
@@ -680,6 +681,27 @@ func TestBlobStorage_AppendAfterFinalize(t *testing.T) {
680 assert.Contains(t, err.Error(), "upload not found")681 assert.Contains(t, err.Error(), "upload not found")
681 }682 }
682 683
684+func TestBlobStorage_FinishBlobUploadMarksBlobAsRecentlyFinalized(t *testing.T) {
685+ tmpDir := t.TempDir()
686+ storage, err := NewBlobStorage(tmpDir, testLogger())
687+ require.NoError(t, err)
688+
689+ uuid, err := storage.StartBlobUpload("myapp")
690+ require.NoError(t, err)
691+ blobData := []byte("finalized content")
692+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
693+ require.NoError(t, err)
694+
695+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
696+ old := time.Now().Add(-2 * 24 * time.Hour)
697+ require.NoError(t, os.Chtimes(uploadPath, old, old))
698+
699+ require.NoError(t, storage.FinishBlobUpload(uuid, digestForData(blobData)))
700+ modTime, err := storage.GetBlobModTime(digestForData(blobData))
701+ require.NoError(t, err)
702+ assert.WithinDuration(t, time.Now(), modTime, time.Minute)
703+}
704+
683 func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {705 func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {
684 tmpDir := t.TempDir()706 tmpDir := t.TempDir()
685 log := testLogger()707 log := testLogger()
M internal/adapters/out/logwriter/logwriter.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 "sync/atomic"
1212
1313 "github.com/bnema/zerowrap"
14- "github.com/docker/docker/pkg/stdcopy"
14+ "github.com/moby/moby/api/pkg/stdcopy"
1515 "gopkg.in/natefinch/lumberjack.v2"
1616 )
1717
@@ -11,7 +11,7 @@ import (
11 "sync/atomic"11 "sync/atomic"
12 12
13 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
14- "github.com/docker/docker/pkg/stdcopy"14+ "github.com/moby/moby/api/pkg/stdcopy"
15 "gopkg.in/natefinch/lumberjack.v2"15 "gopkg.in/natefinch/lumberjack.v2"
16 )16 )
17 17
M internal/app/run.go +24 -0
@@ -411,10 +411,31 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
411411 if err := v.Unmarshal(&cfg); err != nil {
412412 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
413413 }
414+ if err := validateEntrypointMigration(v, cfg); err != nil {
415+ return nil, Config{}, err
416+ }
414417
415418 return v, cfg, nil
416419 }
417420
421+func validateEntrypointMigration(v *viper.Viper, cfg Config) error {
422+ if len(cfg.EntryPoints) > 0 {
423+ return nil
424+ }
425+
426+ legacyKeys := make([]string, 0, 2)
427+ for _, key := range []string{"server.port", "server.tls_port"} {
428+ if v.IsSet(key) {
429+ legacyKeys = append(legacyKeys, key)
430+ }
431+ }
432+ if len(legacyKeys) == 0 {
433+ return nil
434+ }
435+
436+ return fmt.Errorf("legacy %s configuration requires at least one [entrypoints] entry; see docs/upgrading.md", strings.Join(legacyKeys, " or "))
437+}
438+
418439 // initLogger initializes the zerowrap logger.
419440 func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
420441 logConfig := zerowrap.Config{
@@ -1835,6 +1856,9 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
18351856 c.log.Error().Err(err).Msg("failed to unmarshal config on reload")
18361857 return fmt.Errorf("failed to unmarshal config on reload: %w", err)
18371858 }
1859+ if err := validateEntrypointMigration(c.v, reloadCfg); err != nil {
1860+ return err
1861+ }
18381862
18391863 reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)
18401864 if err != nil {
@@ -411,10 +411,31 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
411 if err := v.Unmarshal(&cfg); err != nil {411 if err := v.Unmarshal(&cfg); err != nil {
412 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)412 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
413 }413 }
414+ if err := validateEntrypointMigration(v, cfg); err != nil {
415+ return nil, Config{}, err
416+ }
414 417
415 return v, cfg, nil418 return v, cfg, nil
416 }419 }
417 420
421+func validateEntrypointMigration(v *viper.Viper, cfg Config) error {
422+ if len(cfg.EntryPoints) > 0 {
423+ return nil
424+ }
425+
426+ legacyKeys := make([]string, 0, 2)
427+ for _, key := range []string{"server.port", "server.tls_port"} {
428+ if v.IsSet(key) {
429+ legacyKeys = append(legacyKeys, key)
430+ }
431+ }
432+ if len(legacyKeys) == 0 {
433+ return nil
434+ }
435+
436+ return fmt.Errorf("legacy %s configuration requires at least one [entrypoints] entry; see docs/upgrading.md", strings.Join(legacyKeys, " or "))
437+}
438+
418 // initLogger initializes the zerowrap logger.439 // initLogger initializes the zerowrap logger.
419 func initLogger(cfg Config) (zerowrap.Logger, func(), error) {440 func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
420 logConfig := zerowrap.Config{441 logConfig := zerowrap.Config{
@@ -1835,6 +1856,9 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
1835 c.log.Error().Err(err).Msg("failed to unmarshal config on reload")1856 c.log.Error().Err(err).Msg("failed to unmarshal config on reload")
1836 return fmt.Errorf("failed to unmarshal config on reload: %w", err)1857 return fmt.Errorf("failed to unmarshal config on reload: %w", err)
1837 }1858 }
1859+ if err := validateEntrypointMigration(c.v, reloadCfg); err != nil {
1860+ return err
1861+ }
1838 1862
1839 reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)1863 reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)
1840 if err != nil {1864 if err != nil {
A internal/app/run_entrypoint_migration_test.go +28 -0
@@ -0,0 +1,28 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestInitConfigRejectsLegacyPortsWithoutEntrypoints(t *testing.T) {
12+ configPath := filepath.Join(t.TempDir(), "gordon.toml")
13+ require.NoError(t, os.WriteFile(configPath, []byte("[server]\nport = 8088\ntls_port = 8443\n"), 0o600))
14+
15+ _, _, err := initConfig(configPath)
16+
17+ require.ErrorContains(t, err, "legacy server.port or server.tls_port configuration requires at least one [entrypoints] entry")
18+}
19+
20+func TestInitConfigAcceptsLegacyPortsWithEntrypoint(t *testing.T) {
21+ configPath := filepath.Join(t.TempDir(), "gordon.toml")
22+ require.NoError(t, os.WriteFile(configPath, []byte("[server]\nport = 8088\n\n[entrypoints.edge]\naddress = \":443\"\nprotocol = \"smart_tcp\"\n"), 0o600))
23+
24+ _, cfg, err := initConfig(configPath)
25+
26+ require.NoError(t, err)
27+ require.Contains(t, cfg.EntryPoints, "edge")
28+}
@@ -0,0 +1,28 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestInitConfigRejectsLegacyPortsWithoutEntrypoints(t *testing.T) {
12+ configPath := filepath.Join(t.TempDir(), "gordon.toml")
13+ require.NoError(t, os.WriteFile(configPath, []byte("[server]\nport = 8088\ntls_port = 8443\n"), 0o600))
14+
15+ _, _, err := initConfig(configPath)
16+
17+ require.ErrorContains(t, err, "legacy server.port or server.tls_port configuration requires at least one [entrypoints] entry")
18+}
19+
20+func TestInitConfigAcceptsLegacyPortsWithEntrypoint(t *testing.T) {
21+ configPath := filepath.Join(t.TempDir(), "gordon.toml")
22+ require.NoError(t, os.WriteFile(configPath, []byte("[server]\nport = 8088\n\n[entrypoints.edge]\naddress = \":443\"\nprotocol = \"smart_tcp\"\n"), 0o600))
23+
24+ _, cfg, err := initConfig(configPath)
25+
26+ require.NoError(t, err)
27+ require.Contains(t, cfg.EntryPoints, "edge")
28+}
M internal/boundaries/out/mocks/mock_blob_storage.go +57 -0
@@ -646,6 +646,63 @@ func (_c *MockBlobStorage_ListBlobs_Call) RunAndReturn(run func() ([]string, err
646646 return _c
647647 }
648648
649+// GetBlobModTime provides a mock function for the type MockBlobStorage.
650+func (_mock *MockBlobStorage) GetBlobModTime(digest string) (time.Time, error) {
651+ ret := _mock.Called(digest)
652+
653+ if len(ret) == 0 {
654+ panic("no return value specified for GetBlobModTime")
655+ }
656+
657+ var r0 time.Time
658+ var r1 error
659+ if returnFunc, ok := ret.Get(0).(func(string) (time.Time, error)); ok {
660+ return returnFunc(digest)
661+ }
662+ if returnFunc, ok := ret.Get(0).(func(string) time.Time); ok {
663+ r0 = returnFunc(digest)
664+ } else if ret.Get(0) != nil {
665+ r0 = ret.Get(0).(time.Time)
666+ }
667+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
668+ r1 = returnFunc(digest)
669+ } else {
670+ r1 = ret.Error(1)
671+ }
672+ return r0, r1
673+}
674+
675+// MockBlobStorage_GetBlobModTime_Call is a typed expectation for GetBlobModTime.
676+type MockBlobStorage_GetBlobModTime_Call struct {
677+ *mock.Call
678+}
679+
680+// GetBlobModTime is a helper method to define mock.On call.
681+func (_e *MockBlobStorage_Expecter) GetBlobModTime(digest any) *MockBlobStorage_GetBlobModTime_Call {
682+ return &MockBlobStorage_GetBlobModTime_Call{Call: _e.mock.On("GetBlobModTime", digest)}
683+}
684+
685+func (_c *MockBlobStorage_GetBlobModTime_Call) Run(run func(digest string)) *MockBlobStorage_GetBlobModTime_Call {
686+ _c.Call.Run(func(args mock.Arguments) {
687+ var arg0 string
688+ if args[0] != nil {
689+ arg0 = args[0].(string)
690+ }
691+ run(arg0)
692+ })
693+ return _c
694+}
695+
696+func (_c *MockBlobStorage_GetBlobModTime_Call) Return(modTime time.Time, err error) *MockBlobStorage_GetBlobModTime_Call {
697+ _c.Call.Return(modTime, err)
698+ return _c
699+}
700+
701+func (_c *MockBlobStorage_GetBlobModTime_Call) RunAndReturn(run func(digest string) (time.Time, error)) *MockBlobStorage_GetBlobModTime_Call {
702+ _c.Call.Return(run)
703+ return _c
704+}
705+
649706 // PutBlob provides a mock function for the type MockBlobStorage
650707 func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
651708 ret := _mock.Called(digest, data, size)
@@ -646,6 +646,63 @@ func (_c *MockBlobStorage_ListBlobs_Call) RunAndReturn(run func() ([]string, err
646 return _c646 return _c
647 }647 }
648 648
649+// GetBlobModTime provides a mock function for the type MockBlobStorage.
650+func (_mock *MockBlobStorage) GetBlobModTime(digest string) (time.Time, error) {
651+ ret := _mock.Called(digest)
652+
653+ if len(ret) == 0 {
654+ panic("no return value specified for GetBlobModTime")
655+ }
656+
657+ var r0 time.Time
658+ var r1 error
659+ if returnFunc, ok := ret.Get(0).(func(string) (time.Time, error)); ok {
660+ return returnFunc(digest)
661+ }
662+ if returnFunc, ok := ret.Get(0).(func(string) time.Time); ok {
663+ r0 = returnFunc(digest)
664+ } else if ret.Get(0) != nil {
665+ r0 = ret.Get(0).(time.Time)
666+ }
667+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
668+ r1 = returnFunc(digest)
669+ } else {
670+ r1 = ret.Error(1)
671+ }
672+ return r0, r1
673+}
674+
675+// MockBlobStorage_GetBlobModTime_Call is a typed expectation for GetBlobModTime.
676+type MockBlobStorage_GetBlobModTime_Call struct {
677+ *mock.Call
678+}
679+
680+// GetBlobModTime is a helper method to define mock.On call.
681+func (_e *MockBlobStorage_Expecter) GetBlobModTime(digest any) *MockBlobStorage_GetBlobModTime_Call {
682+ return &MockBlobStorage_GetBlobModTime_Call{Call: _e.mock.On("GetBlobModTime", digest)}
683+}
684+
685+func (_c *MockBlobStorage_GetBlobModTime_Call) Run(run func(digest string)) *MockBlobStorage_GetBlobModTime_Call {
686+ _c.Call.Run(func(args mock.Arguments) {
687+ var arg0 string
688+ if args[0] != nil {
689+ arg0 = args[0].(string)
690+ }
691+ run(arg0)
692+ })
693+ return _c
694+}
695+
696+func (_c *MockBlobStorage_GetBlobModTime_Call) Return(modTime time.Time, err error) *MockBlobStorage_GetBlobModTime_Call {
697+ _c.Call.Return(modTime, err)
698+ return _c
699+}
700+
701+func (_c *MockBlobStorage_GetBlobModTime_Call) RunAndReturn(run func(digest string) (time.Time, error)) *MockBlobStorage_GetBlobModTime_Call {
702+ _c.Call.Return(run)
703+ return _c
704+}
705+
649 // PutBlob provides a mock function for the type MockBlobStorage706 // PutBlob provides a mock function for the type MockBlobStorage
650 func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error {707 func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
651 ret := _mock.Called(digest, data, size)708 ret := _mock.Called(digest, data, size)
M internal/boundaries/out/storage.go +3 -0
@@ -26,6 +26,9 @@ type BlobStorage interface {
2626 // ListBlobs returns all blob digests.
2727 ListBlobs() ([]string, error)
2828
29+ // GetBlobModTime returns the last modification time for a blob.
30+ GetBlobModTime(digest string) (time.Time, error)
31+
2932 // StartBlobUpload starts a new blob upload and returns the upload UUID.
3033 StartBlobUpload(name string) (string, error)
3134
@@ -26,6 +26,9 @@ type BlobStorage interface {
26 // ListBlobs returns all blob digests.26 // ListBlobs returns all blob digests.
27 ListBlobs() ([]string, error)27 ListBlobs() ([]string, error)
28 28
29+ // GetBlobModTime returns the last modification time for a blob.
30+ GetBlobModTime(digest string) (time.Time, error)
31+
29 // StartBlobUpload starts a new blob upload and returns the upload UUID.32 // StartBlobUpload starts a new blob upload and returns the upload UUID.
30 StartBlobUpload(name string) (string, error)33 StartBlobUpload(name string) (string, error)
31 34
M internal/usecase/images/service.go +35 -15
@@ -248,26 +248,17 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
248248 }
249249 }
250250
251- for digest := range s.registryState.PendingDigests(time.Now().UTC()) {
251+ now := time.Now().UTC()
252+ for digest := range s.registryState.PendingDigests(now) {
252253 referencedDigests[digest] = struct{}{}
253254 }
254255
255- blobs, err := s.blobStorage.ListBlobs()
256+ blobsRemoved, spaceReclaimed, err := s.pruneUnreferencedBlobs(log, now, referencedDigests)
256257 if err != nil {
257- return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
258- }
259-
260- for _, digest := range blobs {
261- if _, referenced := referencedDigests[digest]; referenced {
262- continue
263- }
264- size, err := s.blobStorage.DeleteBlob(digest)
265- if err != nil {
266- return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
267- }
268- report.Registry.BlobsRemoved++
269- report.Registry.SpaceReclaimed += size
258+ return domain.ImagePruneReport{}, err
270259 }
260+ report.Registry.BlobsRemoved = blobsRemoved
261+ report.Registry.SpaceReclaimed = spaceReclaimed
271262
272263 // Clean up stale uploads (abandoned push operations)
273264 uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(24 * time.Hour)
@@ -281,6 +272,35 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
281272 return report, nil
282273 }
283274
275+func (s *Service) pruneUnreferencedBlobs(log zerowrap.Logger, now time.Time, referencedDigests map[string]struct{}) (int, int64, error) {
276+ blobs, err := s.blobStorage.ListBlobs()
277+ if err != nil {
278+ return 0, 0, log.WrapErr(err, "failed to list blobs")
279+ }
280+
281+ var removed int
282+ var spaceReclaimed int64
283+ for _, digest := range blobs {
284+ if _, referenced := referencedDigests[digest]; referenced {
285+ continue
286+ }
287+ modTime, err := s.blobStorage.GetBlobModTime(digest)
288+ if err != nil {
289+ return 0, 0, log.WrapErr(err, "failed to get blob modification time")
290+ }
291+ if now.Sub(modTime) <= registrystate.PendingBlobTTL {
292+ continue
293+ }
294+ size, err := s.blobStorage.DeleteBlob(digest)
295+ if err != nil {
296+ return 0, 0, log.WrapErr(err, "failed to delete blob")
297+ }
298+ removed++
299+ spaceReclaimed += size
300+ }
301+ return removed, spaceReclaimed, nil
302+}
303+
284304 func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {
285305 tags, err := s.manifestStorage.ListTags(repository)
286306 if err != nil {
@@ -248,26 +248,17 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
248 }248 }
249 }249 }
250 250
251- for digest := range s.registryState.PendingDigests(time.Now().UTC()) {251+ now := time.Now().UTC()
252+ for digest := range s.registryState.PendingDigests(now) {
252 referencedDigests[digest] = struct{}{}253 referencedDigests[digest] = struct{}{}
253 }254 }
254 255
255- blobs, err := s.blobStorage.ListBlobs()256+ blobsRemoved, spaceReclaimed, err := s.pruneUnreferencedBlobs(log, now, referencedDigests)
256 if err != nil {257 if err != nil {
257- return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")258+ return domain.ImagePruneReport{}, err
258- }
259-
260- for _, digest := range blobs {
261- if _, referenced := referencedDigests[digest]; referenced {
262- continue
263- }
264- size, err := s.blobStorage.DeleteBlob(digest)
265- if err != nil {
266- return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
267- }
268- report.Registry.BlobsRemoved++
269- report.Registry.SpaceReclaimed += size
270 }259 }
260+ report.Registry.BlobsRemoved = blobsRemoved
261+ report.Registry.SpaceReclaimed = spaceReclaimed
271 262
272 // Clean up stale uploads (abandoned push operations)263 // Clean up stale uploads (abandoned push operations)
273 uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(24 * time.Hour)264 uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(24 * time.Hour)
@@ -281,6 +272,35 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
281 return report, nil272 return report, nil
282 }273 }
283 274
275+func (s *Service) pruneUnreferencedBlobs(log zerowrap.Logger, now time.Time, referencedDigests map[string]struct{}) (int, int64, error) {
276+ blobs, err := s.blobStorage.ListBlobs()
277+ if err != nil {
278+ return 0, 0, log.WrapErr(err, "failed to list blobs")
279+ }
280+
281+ var removed int
282+ var spaceReclaimed int64
283+ for _, digest := range blobs {
284+ if _, referenced := referencedDigests[digest]; referenced {
285+ continue
286+ }
287+ modTime, err := s.blobStorage.GetBlobModTime(digest)
288+ if err != nil {
289+ return 0, 0, log.WrapErr(err, "failed to get blob modification time")
290+ }
291+ if now.Sub(modTime) <= registrystate.PendingBlobTTL {
292+ continue
293+ }
294+ size, err := s.blobStorage.DeleteBlob(digest)
295+ if err != nil {
296+ return 0, 0, log.WrapErr(err, "failed to delete blob")
297+ }
298+ removed++
299+ spaceReclaimed += size
300+ }
301+ return removed, spaceReclaimed, nil
302+}
303+
284 func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {304 func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {
285 tags, err := s.manifestStorage.ListTags(repository)305 tags, err := s.manifestStorage.ListTags(repository)
286 if err != nil {306 if err != nil {
M internal/usecase/images/service_test.go +20 -0
@@ -333,6 +333,21 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
333333 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
334334 }
335335
336+func TestService_PruneRegistry_PreservesRecentlyFinalizedBlobAfterRestart(t *testing.T) {
337+ manifestStorage := newFakeManifestStorage()
338+ blobStorage := &fakeBlobStorage{
339+ blobs: []string{"sha256:recent"},
340+ blobModTimes: map[string]time.Time{"sha256:recent": time.Now().UTC()},
341+ }
342+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), registrystate.New())
343+
344+ report, err := svc.PruneRegistry(context.Background(), 1)
345+
346+ require.NoError(t, err)
347+ assert.Zero(t, report.Registry.BlobsRemoved)
348+ assert.Empty(t, blobStorage.deletedBlobs)
349+}
350+
336351 func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) {
337352 manifestStorage := newFakeManifestStorage()
338353 manifestStorage.repositories = []string{"gordon/api"}
@@ -687,6 +702,7 @@ type fakeBlobStorage struct {
687702
688703 blobs []string
689704 blobSizes map[string]int64
705+ blobModTimes map[string]time.Time
690706 deletedBlobs []string
691707 }
692708
@@ -813,6 +829,10 @@ func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
813829 return append([]string(nil), f.blobs...), nil
814830 }
815831
832+func (f *fakeBlobStorage) GetBlobModTime(digest string) (time.Time, error) {
833+ return f.blobModTimes[digest], nil
834+}
835+
816836 func (f *fakeBlobStorage) DeleteBlob(digest string) (int64, error) {
817837 f.deletedBlobs = append(f.deletedBlobs, digest)
818838 var size int64
@@ -333,6 +333,21 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
333 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)333 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
334 }334 }
335 335
336+func TestService_PruneRegistry_PreservesRecentlyFinalizedBlobAfterRestart(t *testing.T) {
337+ manifestStorage := newFakeManifestStorage()
338+ blobStorage := &fakeBlobStorage{
339+ blobs: []string{"sha256:recent"},
340+ blobModTimes: map[string]time.Time{"sha256:recent": time.Now().UTC()},
341+ }
342+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default(), registrystate.New())
343+
344+ report, err := svc.PruneRegistry(context.Background(), 1)
345+
346+ require.NoError(t, err)
347+ assert.Zero(t, report.Registry.BlobsRemoved)
348+ assert.Empty(t, blobStorage.deletedBlobs)
349+}
350+
336 func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) {351 func TestService_PruneRegistry_PreservesPendingBlob(t *testing.T) {
337 manifestStorage := newFakeManifestStorage()352 manifestStorage := newFakeManifestStorage()
338 manifestStorage.repositories = []string{"gordon/api"}353 manifestStorage.repositories = []string{"gordon/api"}
@@ -687,6 +702,7 @@ type fakeBlobStorage struct {
687 702
688 blobs []string703 blobs []string
689 blobSizes map[string]int64704 blobSizes map[string]int64
705+ blobModTimes map[string]time.Time
690 deletedBlobs []string706 deletedBlobs []string
691 }707 }
692 708
@@ -813,6 +829,10 @@ func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
813 return append([]string(nil), f.blobs...), nil829 return append([]string(nil), f.blobs...), nil
814 }830 }
815 831
832+func (f *fakeBlobStorage) GetBlobModTime(digest string) (time.Time, error) {
833+ return f.blobModTimes[digest], nil
834+}
835+
816 func (f *fakeBlobStorage) DeleteBlob(digest string) (int64, error) {836 func (f *fakeBlobStorage) DeleteBlob(digest string) (int64, error) {
817 f.deletedBlobs = append(f.deletedBlobs, digest)837 f.deletedBlobs = append(f.deletedBlobs, digest)
818 var size int64838 var size int64
M internal/usecase/registry/service.go +7 -0
@@ -495,6 +495,12 @@ func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data i
495495
496496 // FinishUpload completes a blob upload.
497497 func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error {
498+ // Keep the transition from upload to blob storage atomic with respect to
499+ // registry garbage collection. PruneRegistry holds the exclusive lock, so
500+ // it cannot observe a finalized blob before it is marked pending.
501+ s.mutationMu.RLock()
502+ defer s.mutationMu.RUnlock()
503+
498504 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
499505 zerowrap.FieldLayer: "usecase",
500506 zerowrap.FieldUseCase: "FinishUpload",
@@ -506,6 +512,7 @@ func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error {
506512 if err := s.blobStorage.FinishBlobUpload(uuid, digest); err != nil {
507513 return log.WrapErr(err, "failed to finish blob upload")
508514 }
515+ s.registryState.AddPending(digest, time.Now().UTC())
509516
510517 log.Info().Msg("blob upload finished")
511518 return nil
@@ -495,6 +495,12 @@ func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data i
495 495
496 // FinishUpload completes a blob upload.496 // FinishUpload completes a blob upload.
497 func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error {497 func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error {
498+ // Keep the transition from upload to blob storage atomic with respect to
499+ // registry garbage collection. PruneRegistry holds the exclusive lock, so
500+ // it cannot observe a finalized blob before it is marked pending.
501+ s.mutationMu.RLock()
502+ defer s.mutationMu.RUnlock()
503+
498 ctx = zerowrap.CtxWithFields(ctx, map[string]any{504 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
499 zerowrap.FieldLayer: "usecase",505 zerowrap.FieldLayer: "usecase",
500 zerowrap.FieldUseCase: "FinishUpload",506 zerowrap.FieldUseCase: "FinishUpload",
@@ -506,6 +512,7 @@ func (s *Service) FinishUpload(ctx context.Context, uuid, digest string) error {
506 if err := s.blobStorage.FinishBlobUpload(uuid, digest); err != nil {512 if err := s.blobStorage.FinishBlobUpload(uuid, digest); err != nil {
507 return log.WrapErr(err, "failed to finish blob upload")513 return log.WrapErr(err, "failed to finish blob upload")
508 }514 }
515+ s.registryState.AddPending(digest, time.Now().UTC())
509 516
510 log.Info().Msg("blob upload finished")517 log.Info().Msg("blob upload finished")
511 return nil518 return nil
M internal/usecase/registry/service_test.go +62 -0
@@ -456,6 +456,68 @@ func TestService_FinishUpload_Success(t *testing.T) {
456456 assert.NoError(t, err)
457457 }
458458
459+func TestService_FinishUploadMarksBlobPending(t *testing.T) {
460+ blobStorage := mocks.NewMockBlobStorage(t)
461+ manifestStorage := mocks.NewMockManifestStorage(t)
462+ eventBus := mocks.NewMockEventPublisher(t)
463+ state := registrystate.New()
464+ svc := NewService(blobStorage, manifestStorage, eventBus, state)
465+ const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
466+
467+ blobStorage.EXPECT().FinishBlobUpload("1234567890-myapp", digest).Return(nil)
468+
469+ require.NoError(t, svc.FinishUpload(testContext(), "1234567890-myapp", digest))
470+ assert.Contains(t, state.PendingDigests(time.Now().UTC()), digest)
471+}
472+
473+func TestService_FinishUploadBlocksRegistryPruning(t *testing.T) {
474+ blobStorage := mocks.NewMockBlobStorage(t)
475+ manifestStorage := mocks.NewMockManifestStorage(t)
476+ eventBus := mocks.NewMockEventPublisher(t)
477+ state := registrystate.New()
478+ svc := NewService(blobStorage, manifestStorage, eventBus, state)
479+ started := make(chan struct{})
480+ release := make(chan struct{})
481+
482+ blobStorage.EXPECT().FinishBlobUpload("1234567890-myapp", mock.Anything).RunAndReturn(func(string, string) error {
483+ close(started)
484+ <-release
485+ return nil
486+ })
487+
488+ finished := make(chan error, 1)
489+ go func() {
490+ finished <- svc.FinishUpload(testContext(), "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4")
491+ }()
492+ <-started
493+
494+ pruneLockAcquired := make(chan struct{})
495+ go func() {
496+ state.MutationMu.Lock()
497+ close(pruneLockAcquired)
498+ state.MutationMu.Unlock()
499+ }()
500+ assert.Never(t, func() bool {
501+ select {
502+ case <-pruneLockAcquired:
503+ return true
504+ default:
505+ return false
506+ }
507+ }, 100*time.Millisecond, 10*time.Millisecond)
508+
509+ close(release)
510+ require.NoError(t, <-finished)
511+ require.Eventually(t, func() bool {
512+ select {
513+ case <-pruneLockAcquired:
514+ return true
515+ default:
516+ return false
517+ }
518+ }, time.Second, 10*time.Millisecond)
519+}
520+
459521 func TestService_FinishUpload_Error(t *testing.T) {
460522 blobStorage := mocks.NewMockBlobStorage(t)
461523 manifestStorage := mocks.NewMockManifestStorage(t)
@@ -456,6 +456,68 @@ func TestService_FinishUpload_Success(t *testing.T) {
456 assert.NoError(t, err)456 assert.NoError(t, err)
457 }457 }
458 458
459+func TestService_FinishUploadMarksBlobPending(t *testing.T) {
460+ blobStorage := mocks.NewMockBlobStorage(t)
461+ manifestStorage := mocks.NewMockManifestStorage(t)
462+ eventBus := mocks.NewMockEventPublisher(t)
463+ state := registrystate.New()
464+ svc := NewService(blobStorage, manifestStorage, eventBus, state)
465+ const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
466+
467+ blobStorage.EXPECT().FinishBlobUpload("1234567890-myapp", digest).Return(nil)
468+
469+ require.NoError(t, svc.FinishUpload(testContext(), "1234567890-myapp", digest))
470+ assert.Contains(t, state.PendingDigests(time.Now().UTC()), digest)
471+}
472+
473+func TestService_FinishUploadBlocksRegistryPruning(t *testing.T) {
474+ blobStorage := mocks.NewMockBlobStorage(t)
475+ manifestStorage := mocks.NewMockManifestStorage(t)
476+ eventBus := mocks.NewMockEventPublisher(t)
477+ state := registrystate.New()
478+ svc := NewService(blobStorage, manifestStorage, eventBus, state)
479+ started := make(chan struct{})
480+ release := make(chan struct{})
481+
482+ blobStorage.EXPECT().FinishBlobUpload("1234567890-myapp", mock.Anything).RunAndReturn(func(string, string) error {
483+ close(started)
484+ <-release
485+ return nil
486+ })
487+
488+ finished := make(chan error, 1)
489+ go func() {
490+ finished <- svc.FinishUpload(testContext(), "1234567890-myapp", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4")
491+ }()
492+ <-started
493+
494+ pruneLockAcquired := make(chan struct{})
495+ go func() {
496+ state.MutationMu.Lock()
497+ close(pruneLockAcquired)
498+ state.MutationMu.Unlock()
499+ }()
500+ assert.Never(t, func() bool {
501+ select {
502+ case <-pruneLockAcquired:
503+ return true
504+ default:
505+ return false
506+ }
507+ }, 100*time.Millisecond, 10*time.Millisecond)
508+
509+ close(release)
510+ require.NoError(t, <-finished)
511+ require.Eventually(t, func() bool {
512+ select {
513+ case <-pruneLockAcquired:
514+ return true
515+ default:
516+ return false
517+ }
518+ }, time.Second, 10*time.Millisecond)
519+}
520+
459 func TestService_FinishUpload_Error(t *testing.T) {521 func TestService_FinishUpload_Error(t *testing.T) {
460 blobStorage := mocks.NewMockBlobStorage(t)522 blobStorage := mocks.NewMockBlobStorage(t)
461 manifestStorage := mocks.NewMockManifestStorage(t)523 manifestStorage := mocks.NewMockManifestStorage(t)
M pkg/registrypush/push.go +2 -2
@@ -15,11 +15,11 @@ import (
1515 "strings"
1616 "time"
1717
18- "github.com/docker/docker/client"
1918 "github.com/google/go-containerregistry/pkg/authn"
2019 "github.com/google/go-containerregistry/pkg/name"
2120 v1 "github.com/google/go-containerregistry/pkg/v1"
2221 "github.com/google/go-containerregistry/pkg/v1/tarball"
22+ "github.com/moby/moby/client"
2323 )
2424
2525 // DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit
@@ -207,7 +207,7 @@ func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, s
207207
208208 // defaultImageSourceWithCleanup reads an image from the local Docker/Podman daemon.
209209 func defaultImageSourceWithCleanup(ctx context.Context, ref string) (v1.Image, func(), error) {
210- cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
210+ cli, err := client.New(client.FromEnv)
211211 if err != nil {
212212 return nil, nil, fmt.Errorf("failed to create Docker client: %w", err)
213213 }
@@ -15,11 +15,11 @@ import (
15 "strings"15 "strings"
16 "time"16 "time"
17 17
18- "github.com/docker/docker/client"
19 "github.com/google/go-containerregistry/pkg/authn"18 "github.com/google/go-containerregistry/pkg/authn"
20 "github.com/google/go-containerregistry/pkg/name"19 "github.com/google/go-containerregistry/pkg/name"
21 v1 "github.com/google/go-containerregistry/pkg/v1"20 v1 "github.com/google/go-containerregistry/pkg/v1"
22 "github.com/google/go-containerregistry/pkg/v1/tarball"21 "github.com/google/go-containerregistry/pkg/v1/tarball"
22+ "github.com/moby/moby/client"
23 )23 )
24 24
25 // DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit25 // DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit
@@ -207,7 +207,7 @@ func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, s
207 207
208 // defaultImageSourceWithCleanup reads an image from the local Docker/Podman daemon.208 // defaultImageSourceWithCleanup reads an image from the local Docker/Podman daemon.
209 func defaultImageSourceWithCleanup(ctx context.Context, ref string) (v1.Image, func(), error) {209 func defaultImageSourceWithCleanup(ctx context.Context, ref string) (v1.Image, func(), error) {
210- cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())210+ cli, err := client.New(client.FromEnv)
211 if err != nil {211 if err != nil {
212 return nil, nil, fmt.Errorf("failed to create Docker client: %w", err)212 return nil, nil, fmt.Errorf("failed to create Docker client: %w", err)
213 }213 }

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close