fix(backend): move Docker client to the maintained moby client module (#1935)

github.com/docker/docker is frozen at v28.5.2 — moby publishes v29+ under
github.com/moby/moby/v2, with the API client split out into the standalone
github.com/moby/moby/client and github.com/moby/moby/api modules. That leaves
five Dependabot alerts on the old module path with no reachable fix version:

  CVE-2026-42306  high    docker cp bind mount redirection      <= 28.5.2
  CVE-2026-41567  high    PUT /containers/{id}/archive          <= 28.5.2
  CVE-2026-41568  medium  docker cp symlink swap                <= 28.5.2
  CVE-2026-34040  high    AuthZ plugin bypass                   < 29.3.1
  CVE-2026-33997  medium  plugin privilege off-by-one           < 29.3.1

All five are daemon-side defects, and we only ever imported the client. Moving
to the split client module drops the daemon source out of the module graph
entirely, so these alerts resolve and future daemon advisories will not flag us
at all. It also trims six transitive dependencies.

The new client takes an options struct per call and returns a result struct, so
this is a mechanical rewrite of the wrapper. Behavioural notes:

- Ports moved from docker/go-connections/nat to moby/api/types/network, where
  Port is opaque and PortBinding.HostIP is a netip.Addr. buildDockerPortBindings
  now returns an error for out-of-range ports and unparseable host IPs instead
  of handing the daemon a malformed request. An empty HostIP still maps to the
  zero address, preserving "publish on all interfaces".
- DiskUsage needs Verbose to get per-image and per-container records; the
  aggregate LayersSize is now Images.TotalSize.
- BuildCachePrune no longer takes KeepStorage — the client derives the legacy
  keep-storage parameter from ReservedSpace for daemons on API <= 1.47.
- ImagesPrune is now ImagePrune.
- Container state and health are typed string enums, and InspectResponse.Config
  and .State are pointers, so inspect mapping guards for nil.

Also corrects the RUSTSEC-2024-0429 note in the cargo-audit ignore list. It
claimed the advisory resolves once Tauri moves to webkit2gtk-4.1, but Tauri v2
is already on those bindings and still resolves glib to 0.18.5; the real
blocker is that gtk3-rs is archived. Backport submitted upstream.

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-07-24 23:33:41 +01:00
committed by GitHub
parent f85b648d97
commit 767cc89e6c
8 changed files with 360 additions and 233 deletions
+5 -10
View File
@@ -6,9 +6,7 @@ require (
github.com/UserExistsError/conpty v0.1.4
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/coder/acp-go-sdk v0.13.5
github.com/creack/pty v1.1.21
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
github.com/creack/pty v1.1.24
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -18,6 +16,8 @@ require (
github.com/kevinburke/ssh_config v1.6.0
github.com/mark3labs/mcp-go v0.43.2
github.com/mattn/go-sqlite3 v1.14.33
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.0
github.com/nats-io/nats.go v1.31.0
github.com/pkg/sftp v1.13.10
github.com/spf13/viper v1.16.0
@@ -43,7 +43,7 @@ require (
require (
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Microsoft/go-winio v0.4.21 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
@@ -52,9 +52,9 @@ require (
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -87,18 +87,14 @@ require (
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/nats-io/nkeys v0.4.6 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.7.1 // indirect
@@ -121,7 +117,6 @@ require (
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
replace github.com/coder/acp-go-sdk => github.com/kdlbs/acp-go-sdk v0.13.6-0.20260625174839-e37105a28386
+12 -28
View File
@@ -38,14 +38,12 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to=
github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -78,19 +76,15 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -272,19 +266,15 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E=
github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8=
github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY=
@@ -299,7 +289,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
@@ -310,9 +299,6 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
@@ -501,7 +487,6 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -524,7 +509,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -554,8 +538,6 @@ golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA=
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -727,6 +709,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
@@ -6,9 +6,9 @@ import (
"io"
"testing"
"github.com/docker/docker/api/types/build"
"github.com/kandev/kandev/internal/agent/runtime/activity"
"github.com/kandev/kandev/internal/common/logger"
"github.com/moby/moby/client"
"go.uber.org/zap"
)
@@ -19,7 +19,7 @@ func TestBuildImageHoldsActivityUntilResponseBodyCloses(t *testing.T) {
}
coordinator := activity.NewCoordinator(activity.Options{})
client := &Client{
builder: &fakeImageBuilder{response: build.ImageBuildResponse{
builder: &fakeImageBuilder{response: client.ImageBuildResult{
Body: io.NopCloser(bytes.NewBufferString("build output")),
}},
logger: log,
@@ -44,10 +44,10 @@ func TestBuildImageHoldsActivityUntilResponseBodyCloses(t *testing.T) {
}
type fakeImageBuilder struct {
response build.ImageBuildResponse
response client.ImageBuildResult
err error
}
func (f *fakeImageBuilder) ImageBuild(context.Context, io.Reader, build.ImageBuildOptions) (build.ImageBuildResponse, error) {
func (f *fakeImageBuilder) ImageBuild(context.Context, io.Reader, client.ImageBuildOptions) (client.ImageBuildResult, error) {
return f.response, f.err
}
+161 -85
View File
@@ -8,20 +8,20 @@ import (
"encoding/binary"
"fmt"
"io"
"math"
"net"
"net/netip"
"strconv"
"sync"
"time"
"github.com/docker/docker/api/types/build"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
"github.com/kandev/kandev/internal/agent/runtime/activity"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
"go.uber.org/zap"
)
@@ -72,11 +72,11 @@ type ContainerInfo struct {
// Client wraps the Docker client.
type containerRemover interface {
ContainerRemove(context.Context, string, container.RemoveOptions) error
ContainerRemove(context.Context, string, client.ContainerRemoveOptions) (client.ContainerRemoveResult, error)
}
type imageBuilder interface {
ImageBuild(context.Context, io.Reader, build.ImageBuildOptions) (build.ImageBuildResponse, error)
ImageBuild(context.Context, io.Reader, client.ImageBuildOptions) (client.ImageBuildResult, error)
}
type Client struct {
@@ -104,7 +104,7 @@ func NewClient(cfg config.DockerConfig, log *logger.Logger) (*Client, error) {
opts = append(opts, client.WithVersion(cfg.APIVersion))
}
cli, err := client.NewClientWithOpts(opts...)
cli, err := client.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create docker client: %w", err)
}
@@ -141,7 +141,7 @@ func (c *Client) Close() error {
func (c *Client) PullImage(ctx context.Context, imageName string) error {
c.logger.Info("Pulling image", zap.String("image", imageName))
reader, err := c.cli.ImagePull(ctx, imageName, image.PullOptions{})
reader, err := c.cli.ImagePull(ctx, imageName, client.ImagePullOptions{})
if err != nil {
c.logger.Error("Failed to pull image", zap.String("image", imageName), zap.Error(err))
return fmt.Errorf("failed to pull image %s: %w", imageName, err)
@@ -190,7 +190,7 @@ func (c *Client) BuildImage(ctx context.Context, dockerfile string, tag string,
return nil, fmt.Errorf("failed to create build context: %w", err)
}
resp, err := builder.ImageBuild(ctx, buildContext, build.ImageBuildOptions{
resp, err := builder.ImageBuild(ctx, buildContext, client.ImageBuildOptions{
Tags: []string{tag},
BuildArgs: buildArgs,
Dockerfile: "Dockerfile",
@@ -271,7 +271,10 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
}
// Container configuration
exposedPorts, portBindings := buildDockerPortBindings(cfg.PortBindings)
exposedPorts, portBindings, err := buildDockerPortBindings(cfg.PortBindings)
if err != nil {
return "", fmt.Errorf("failed to create container %s: %w", cfg.Name, err)
}
containerCfg := &container.Config{
Image: cfg.Image,
Entrypoint: cfg.Entrypoint,
@@ -294,7 +297,11 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
},
}
resp, err := c.cli.ContainerCreate(ctx, containerCfg, hostCfg, nil, nil, cfg.Name)
resp, err := c.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: containerCfg,
HostConfig: hostCfg,
Name: cfg.Name,
})
if err != nil {
c.logger.Error("Failed to create container",
zap.String("name", cfg.Name),
@@ -307,28 +314,60 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
return resp.ID, nil
}
func buildDockerPortBindings(bindings []PortBindingConfig) (nat.PortSet, nat.PortMap) {
func buildDockerPortBindings(bindings []PortBindingConfig) (network.PortSet, network.PortMap, error) {
if len(bindings) == 0 {
return nil, nil
return nil, nil, nil
}
exposedPorts := nat.PortSet{}
portBindings := nat.PortMap{}
exposedPorts := network.PortSet{}
portBindings := network.PortMap{}
for _, binding := range bindings {
containerPort := nat.Port(fmt.Sprintf("%d/tcp", binding.ContainerPort))
containerPort, err := tcpPort(binding.ContainerPort)
if err != nil {
return nil, nil, fmt.Errorf("invalid container port binding: %w", err)
}
hostIP, err := parseDockerHostIP(binding.HostIP)
if err != nil {
return nil, nil, fmt.Errorf("invalid host IP for container port %d: %w", binding.ContainerPort, err)
}
exposedPorts[containerPort] = struct{}{}
portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{
HostIP: binding.HostIP,
portBindings[containerPort] = append(portBindings[containerPort], network.PortBinding{
HostIP: hostIP,
HostPort: binding.HostPort,
})
}
return exposedPorts, portBindings
return exposedPorts, portBindings, nil
}
// tcpPort converts a container port number into the API's opaque TCP port key.
func tcpPort(port int) (network.Port, error) {
if port <= 0 || port > math.MaxUint16 {
return network.Port{}, fmt.Errorf("port %d out of range", port)
}
parsed, ok := network.PortFrom(uint16(port), network.TCP)
if !ok {
return network.Port{}, fmt.Errorf("port %d is not a valid TCP port", port)
}
return parsed, nil
}
// parseDockerHostIP converts a configured host IP into the API's address type.
// An empty value keeps Docker's "publish on all interfaces" behavior.
func parseDockerHostIP(hostIP string) (netip.Addr, error) {
if hostIP == "" {
return netip.Addr{}, nil
}
addr, err := netip.ParseAddr(hostIP)
if err != nil {
return netip.Addr{}, fmt.Errorf("parse host IP %q: %w", hostIP, err)
}
return addr, nil
}
// StartContainer starts a container.
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
c.logger.Info("Starting container", zap.String("container_id", containerID))
err := c.cli.ContainerStart(ctx, containerID, container.StartOptions{})
_, err := c.cli.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
if err != nil {
c.logger.Error("Failed to start container", zap.String("container_id", containerID), zap.Error(err))
return fmt.Errorf("failed to start container %s: %w", containerID, err)
@@ -346,7 +385,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID string, timeout
)
timeoutSeconds := int(timeout.Seconds())
err := c.cli.ContainerStop(ctx, containerID, container.StopOptions{
_, err := c.cli.ContainerStop(ctx, containerID, client.ContainerStopOptions{
Timeout: &timeoutSeconds,
})
if err != nil {
@@ -365,7 +404,7 @@ func (c *Client) RemoveContainer(ctx context.Context, containerID string, force
zap.Bool("force", force),
)
err := c.containerRemover().ContainerRemove(ctx, containerID, container.RemoveOptions{
_, err := c.containerRemover().ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{
Force: force,
RemoveVolumes: true,
})
@@ -392,7 +431,7 @@ func (c *Client) KillContainer(ctx context.Context, containerID string, signal s
zap.String("signal", signal),
)
err := c.cli.ContainerKill(ctx, containerID, signal)
_, err := c.cli.ContainerKill(ctx, containerID, client.ContainerKillOptions{Signal: signal})
if err != nil {
c.logger.Error("Failed to kill container", zap.String("container_id", containerID), zap.Error(err))
return fmt.Errorf("failed to kill container %s: %w", containerID, err)
@@ -402,49 +441,59 @@ func (c *Client) KillContainer(ctx context.Context, containerID string, signal s
return nil
}
// inspectContainer inspects a container and unwraps the typed response.
func (c *Client) inspectContainer(ctx context.Context, containerID string) (container.InspectResponse, error) {
result, err := c.cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
if err != nil {
return container.InspectResponse{}, err
}
return result.Container, nil
}
// GetContainerInfo returns information about a container.
func (c *Client) GetContainerInfo(ctx context.Context, containerID string) (*ContainerInfo, error) {
c.logger.Debug("Getting container info", zap.String("container_id", containerID))
inspect, err := c.cli.ContainerInspect(ctx, containerID)
inspect, err := c.inspectContainer(ctx, containerID)
if err != nil {
c.logger.Error("Failed to inspect container", zap.String("container_id", containerID), zap.Error(err))
return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
}
info := &ContainerInfo{
ID: inspect.ID,
Name: inspect.Name,
Image: inspect.Config.Image,
State: inspect.State.Status,
Status: inspect.State.Status,
ExitCode: inspect.State.ExitCode,
Labels: inspect.Config.Labels,
ID: inspect.ID,
Name: inspect.Name,
}
// Parse timestamps
if inspect.State.StartedAt != "" {
startedAt, err := time.Parse(time.RFC3339Nano, inspect.State.StartedAt)
if err == nil {
info.StartedAt = startedAt
}
}
if inspect.State.FinishedAt != "" {
finishedAt, err := time.Parse(time.RFC3339Nano, inspect.State.FinishedAt)
if err == nil {
info.FinishedAt = finishedAt
}
}
// Get health status if available
if inspect.State.Health != nil {
info.Health = inspect.State.Health.Status
if inspect.Config != nil {
info.Image = inspect.Config.Image
info.Labels = inspect.Config.Labels
}
applyContainerState(info, inspect.State)
return info, nil
}
// applyContainerState copies the inspected runtime state onto info. The API
// models State as a pointer, so a missing state leaves the zero values.
func applyContainerState(info *ContainerInfo, state *container.State) {
if state == nil {
return
}
info.State = string(state.Status)
info.Status = string(state.Status)
info.ExitCode = state.ExitCode
if startedAt, err := time.Parse(time.RFC3339Nano, state.StartedAt); err == nil {
info.StartedAt = startedAt
}
if finishedAt, err := time.Parse(time.RFC3339Nano, state.FinishedAt); err == nil {
info.FinishedAt = finishedAt
}
if state.Health != nil {
info.Health = string(state.Health.Status)
}
}
// IsContainerRunning returns true if the container exists and is in "running" state
func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
info, err := c.GetContainerInfo(ctx, containerID)
@@ -458,7 +507,7 @@ func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bo
func (c *Client) GetContainerIP(ctx context.Context, containerID string) (string, error) {
c.logger.Debug("Getting container IP", zap.String("container_id", containerID))
inspect, err := c.cli.ContainerInspect(ctx, containerID)
inspect, err := c.inspectContainer(ctx, containerID)
if err != nil {
c.logger.Error("Failed to inspect container for IP", zap.String("container_id", containerID), zap.Error(err))
return "", err
@@ -467,13 +516,15 @@ func (c *Client) GetContainerIP(ctx context.Context, containerID string) (string
if inspect.NetworkSettings != nil {
// Check available networks for an IP address.
for netName, netSettings := range inspect.NetworkSettings.Networks {
if netSettings.IPAddress != "" {
c.logger.Debug("Found container IP",
zap.String("container_id", containerID),
zap.String("network", netName),
zap.String("ip", netSettings.IPAddress))
return netSettings.IPAddress, nil
if netSettings == nil || !netSettings.IPAddress.IsValid() {
continue
}
ip := netSettings.IPAddress.String()
c.logger.Debug("Found container IP",
zap.String("container_id", containerID),
zap.String("network", netName),
zap.String("ip", ip))
return ip, nil
}
}
@@ -486,7 +537,7 @@ func (c *Client) GetContainerHostPort(ctx context.Context, containerID string, c
zap.String("container_id", containerID),
zap.Int("container_port", containerPort))
inspect, err := c.cli.ContainerInspect(ctx, containerID)
inspect, err := c.inspectContainer(ctx, containerID)
if err != nil {
return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
}
@@ -494,11 +545,15 @@ func (c *Client) GetContainerHostPort(ctx context.Context, containerID string, c
return "", 0, fmt.Errorf("container %s has no published ports", containerID)
}
bindings := inspect.NetworkSettings.Ports[nat.Port(fmt.Sprintf("%d/tcp", containerPort))]
port, err := tcpPort(containerPort)
if err != nil {
return "", 0, fmt.Errorf("container %s: %w", containerID, err)
}
bindings := inspect.NetworkSettings.Ports[port]
if len(bindings) == 0 {
return "", 0, fmt.Errorf("container %s port %d/tcp is not published", containerID, containerPort)
}
hostPort, err := nat.ParsePort(bindings[0].HostPort)
hostPort, err := parseHostPort(bindings[0].HostPort)
if err != nil {
return "", 0, fmt.Errorf("invalid host port for container %s port %d/tcp: %w", containerID, containerPort, err)
}
@@ -506,20 +561,32 @@ func (c *Client) GetContainerHostPort(ctx context.Context, containerID string, c
return hostIP, hostPort, nil
}
func normalizeDockerHostIP(hostIP string) string {
switch hostIP {
case "", "0.0.0.0", "::":
return "127.0.0.1"
default:
return hostIP
// parseHostPort validates a host-side port number reported by the daemon.
func parseHostPort(value string) (int, error) {
port, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("parse port %q: %w", value, err)
}
if port < 0 || port > math.MaxUint16 {
return 0, fmt.Errorf("port %d out of range", port)
}
return port, nil
}
// normalizeDockerHostIP maps an unset or wildcard publish address onto loopback
// so callers get an address they can actually dial.
func normalizeDockerHostIP(hostIP netip.Addr) string {
if !hostIP.IsValid() || hostIP.IsUnspecified() {
return "127.0.0.1"
}
return hostIP.String()
}
// GetContainerLabels returns the labels of a container
func (c *Client) GetContainerLabels(ctx context.Context, containerID string) (map[string]string, error) {
c.logger.Debug("Getting container labels", zap.String("container_id", containerID))
inspect, err := c.cli.ContainerInspect(ctx, containerID)
inspect, err := c.inspectContainer(ctx, containerID)
if err != nil {
c.logger.Error("Failed to inspect container for labels", zap.String("container_id", containerID), zap.Error(err))
return nil, err
@@ -540,7 +607,7 @@ func (c *Client) GetContainerLogs(ctx context.Context, containerID string, follo
zap.String("tail", tail),
)
opts := container.LogsOptions{
opts := client.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: follow,
@@ -561,15 +628,17 @@ func (c *Client) GetContainerLogs(ctx context.Context, containerID string, follo
func (c *Client) WaitContainer(ctx context.Context, containerID string) (int64, error) {
c.logger.Info("Waiting for container", zap.String("container_id", containerID))
statusCh, errCh := c.cli.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
wait := c.cli.ContainerWait(ctx, containerID, client.ContainerWaitOptions{
Condition: container.WaitConditionNotRunning,
})
select {
case err := <-errCh:
case err := <-wait.Error:
if err != nil {
c.logger.Error("Error waiting for container", zap.String("container_id", containerID), zap.Error(err))
return -1, fmt.Errorf("error waiting for container %s: %w", containerID, err)
}
case status := <-statusCh:
case status := <-wait.Result:
c.logger.Info("Container exited",
zap.String("container_id", containerID),
zap.Int64("exit_code", status.StatusCode),
@@ -588,22 +657,22 @@ func (c *Client) ListContainers(ctx context.Context, labels map[string]string) (
c.logger.Debug("Listing containers", zap.Any("labels", labels))
// Build filters from labels
filterArgs := filters.NewArgs()
listFilters := make(client.Filters)
for key, value := range labels {
filterArgs.Add("label", fmt.Sprintf("%s=%s", key, value))
listFilters.Add("label", fmt.Sprintf("%s=%s", key, value))
}
containers, err := c.cli.ContainerList(ctx, container.ListOptions{
result, err := c.cli.ContainerList(ctx, client.ContainerListOptions{
All: true,
Filters: filterArgs,
Filters: listFilters,
})
if err != nil {
c.logger.Error("Failed to list containers", zap.Error(err))
return nil, fmt.Errorf("failed to list containers: %w", err)
}
infos := make([]ContainerInfo, 0, len(containers))
for _, ctr := range containers {
infos := make([]ContainerInfo, 0, len(result.Items))
for _, ctr := range result.Items {
name := ""
if len(ctr.Names) > 0 {
name = ctr.Names[0]
@@ -617,7 +686,7 @@ func (c *Client) ListContainers(ctx context.Context, labels map[string]string) (
ID: ctr.ID,
Name: name,
Image: ctr.Image,
State: ctr.State,
State: string(ctr.State),
Status: ctr.Status,
Labels: ctr.Labels,
}
@@ -632,7 +701,7 @@ func (c *Client) ListContainers(ctx context.Context, labels map[string]string) (
func (c *Client) Ping(ctx context.Context) error {
c.logger.Debug("Pinging Docker daemon")
_, err := c.cli.Ping(ctx)
_, err := c.cli.Ping(ctx, client.PingOptions{})
if err != nil {
c.logger.Debug("Docker ping failed", zap.Error(err))
return fmt.Errorf("docker ping failed: %w", err)
@@ -671,7 +740,10 @@ func (c *Client) CreateContainerInteractive(ctx context.Context, cfg ContainerCo
// Container configuration with stdin attached. Mirror CreateContainer's
// handling of ContainerConfig fields — including Entrypoint — so the same
// config struct produces consistent container behavior on both paths.
exposedPorts, portBindings := buildDockerPortBindings(cfg.PortBindings)
exposedPorts, portBindings, err := buildDockerPortBindings(cfg.PortBindings)
if err != nil {
return "", fmt.Errorf("failed to create interactive container %s: %w", cfg.Name, err)
}
containerCfg := &container.Config{
Image: cfg.Image,
Entrypoint: cfg.Entrypoint,
@@ -700,7 +772,11 @@ func (c *Client) CreateContainerInteractive(ctx context.Context, cfg ContainerCo
},
}
resp, err := c.cli.ContainerCreate(ctx, containerCfg, hostCfg, nil, nil, cfg.Name)
resp, err := c.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: containerCfg,
HostConfig: hostCfg,
Name: cfg.Name,
})
if err != nil {
c.logger.Error("Failed to create interactive container",
zap.String("name", cfg.Name),
@@ -717,7 +793,7 @@ func (c *Client) CreateContainerInteractive(ctx context.Context, cfg ContainerCo
func (c *Client) AttachContainer(ctx context.Context, containerID string) (*AttachResult, error) {
c.logger.Info("Attaching to container", zap.String("container_id", containerID))
opts := container.AttachOptions{
opts := client.ContainerAttachOptions{
Stream: true,
Stdin: true,
Stdout: true,
@@ -2,34 +2,39 @@ package docker
import (
"fmt"
"net/netip"
"testing"
"github.com/docker/go-connections/nat"
"github.com/moby/moby/api/types/network"
)
func TestNormalizeDockerHostIP(t *testing.T) {
cases := []struct {
in string
name string
in netip.Addr
want string
}{
{in: "", want: "127.0.0.1"},
{in: "0.0.0.0", want: "127.0.0.1"},
{in: "::", want: "127.0.0.1"},
{in: "127.0.0.1", want: "127.0.0.1"},
{in: "10.0.0.5", want: "10.0.0.5"},
{in: "::1", want: "::1"},
{name: "unset", in: netip.Addr{}, want: "127.0.0.1"},
{name: "ipv4 wildcard", in: netip.MustParseAddr("0.0.0.0"), want: "127.0.0.1"},
{name: "ipv6 wildcard", in: netip.MustParseAddr("::"), want: "127.0.0.1"},
{name: "ipv4 loopback", in: netip.MustParseAddr("127.0.0.1"), want: "127.0.0.1"},
{name: "ipv4 host", in: netip.MustParseAddr("10.0.0.5"), want: "10.0.0.5"},
{name: "ipv6 loopback", in: netip.MustParseAddr("::1"), want: "::1"},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeDockerHostIP(tc.in); got != tc.want {
t.Errorf("normalizeDockerHostIP(%q) = %q, want %q", tc.in, got, tc.want)
t.Errorf("normalizeDockerHostIP(%v) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestBuildDockerPortBindings_EmptyReturnsNil(t *testing.T) {
exposed, bindings := buildDockerPortBindings(nil)
exposed, bindings, err := buildDockerPortBindings(nil)
if err != nil {
t.Fatalf("buildDockerPortBindings: %v", err)
}
if exposed != nil {
t.Errorf("expected nil exposed ports, got %v", exposed)
}
@@ -43,13 +48,16 @@ func TestBuildDockerPortBindings_AssignsContainerAndHost(t *testing.T) {
{ContainerPort: 8080, HostIP: "127.0.0.1", HostPort: "0"},
{ContainerPort: 9000, HostIP: "0.0.0.0", HostPort: "9001"},
}
exposed, bindings := buildDockerPortBindings(in)
exposed, bindings, err := buildDockerPortBindings(in)
if err != nil {
t.Fatalf("buildDockerPortBindings: %v", err)
}
if got := len(exposed); got != 2 {
t.Fatalf("exposed ports = %d, want 2", got)
}
for _, b := range in {
key := nat.Port(fmt.Sprintf("%d/tcp", b.ContainerPort))
key := network.MustParsePort(fmt.Sprintf("%d/tcp", b.ContainerPort))
if _, ok := exposed[key]; !ok {
t.Errorf("exposed missing %s", key)
}
@@ -57,7 +65,7 @@ func TestBuildDockerPortBindings_AssignsContainerAndHost(t *testing.T) {
if len(got) != 1 {
t.Fatalf("bindings[%s] = %d entries, want 1", key, len(got))
}
if got[0].HostIP != b.HostIP || got[0].HostPort != b.HostPort {
if got[0].HostIP.String() != b.HostIP || got[0].HostPort != b.HostPort {
t.Errorf("bindings[%s] = %+v, want host_ip=%q host_port=%q", key, got[0], b.HostIP, b.HostPort)
}
}
@@ -68,9 +76,57 @@ func TestBuildDockerPortBindings_DeduplicatesContainerPort(t *testing.T) {
{ContainerPort: 7000, HostIP: "127.0.0.1", HostPort: "0"},
{ContainerPort: 7000, HostIP: "10.0.0.5", HostPort: "7000"},
}
_, bindings := buildDockerPortBindings(in)
got := bindings[nat.Port("7000/tcp")]
_, bindings, err := buildDockerPortBindings(in)
if err != nil {
t.Fatalf("buildDockerPortBindings: %v", err)
}
got := bindings[network.MustParsePort("7000/tcp")]
if len(got) != 2 {
t.Fatalf("want both bindings on port 7000, got %d", len(got))
}
}
func TestBuildDockerPortBindings_RejectsInvalidInput(t *testing.T) {
cases := []struct {
name string
in PortBindingConfig
}{
{name: "container port zero", in: PortBindingConfig{ContainerPort: 0, HostPort: "1"}},
{name: "container port too large", in: PortBindingConfig{ContainerPort: 70000, HostPort: "1"}},
{name: "host ip not an address", in: PortBindingConfig{ContainerPort: 8080, HostIP: "localhost"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := buildDockerPortBindings([]PortBindingConfig{tc.in}); err == nil {
t.Fatal("expected an error, got nil")
}
})
}
}
func TestBuildDockerPortBindings_EmptyHostIPPublishesOnAllInterfaces(t *testing.T) {
_, bindings, err := buildDockerPortBindings([]PortBindingConfig{
{ContainerPort: 8080, HostIP: "", HostPort: "8080"},
})
if err != nil {
t.Fatalf("buildDockerPortBindings: %v", err)
}
got := bindings[network.MustParsePort("8080/tcp")]
if len(got) != 1 {
t.Fatalf("bindings = %d entries, want 1", len(got))
}
if got[0].HostIP.IsValid() {
t.Errorf("host IP = %v, want the zero address (all interfaces)", got[0].HostIP)
}
}
func TestParseHostPort(t *testing.T) {
if got, err := parseHostPort("9001"); err != nil || got != 9001 {
t.Fatalf("parseHostPort(9001) = %d, %v", got, err)
}
for _, in := range []string{"", "abc", "70000", "-1"} {
if _, err := parseHostPort(in); err == nil {
t.Errorf("parseHostPort(%q) = nil error, want an error", in)
}
}
}
+33 -39
View File
@@ -7,16 +7,13 @@ import (
"strconv"
"time"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/build"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/moby/moby/client"
)
type storageAPI interface {
DiskUsage(context.Context, dockertypes.DiskUsageOptions) (dockertypes.DiskUsage, error)
BuildCachePrune(context.Context, build.CachePruneOptions) (*build.CachePruneReport, error)
ImagesPrune(context.Context, filters.Args) (image.PruneReport, error)
DiskUsage(context.Context, client.DiskUsageOptions) (client.DiskUsageResult, error)
BuildCachePrune(context.Context, client.BuildCachePruneOptions) (client.BuildCachePruneResult, error)
ImagePrune(context.Context, client.ImagePruneOptions) (client.ImagePruneResult, error)
}
type ImageUsage struct {
@@ -50,39 +47,34 @@ type PruneResult struct {
}
func (c *Client) DiskUsage(ctx context.Context) (DiskUsage, error) {
usage, err := c.storageClient().DiskUsage(ctx, dockertypes.DiskUsageOptions{
Types: []dockertypes.DiskUsageObject{
dockertypes.ContainerObject,
dockertypes.ImageObject,
dockertypes.BuildCacheObject,
},
// Verbose is required for the daemon to return per-image and per-container
// records alongside the aggregate totals.
usage, err := c.storageClient().DiskUsage(ctx, client.DiskUsageOptions{
Containers: true,
Images: true,
BuildCache: true,
Verbose: true,
})
if err != nil {
return DiskUsage{}, fmt.Errorf("read Docker disk usage: %w", err)
}
result := DiskUsage{
ImageLayerBytes: usage.LayersSize,
Images: make([]ImageUsage, 0, len(usage.Images)),
Containers: make([]ContainerUsage, 0, len(usage.Containers)),
ImageLayerBytes: usage.Images.TotalSize,
Images: make([]ImageUsage, 0, len(usage.Images.Items)),
Containers: make([]ContainerUsage, 0, len(usage.Containers.Items)),
}
for _, cache := range usage.BuildCache {
if cache != nil && cache.Size > 0 {
for _, cache := range usage.BuildCache.Items {
if cache.Size > 0 {
result.BuildCacheBytes += cache.Size
}
}
for _, item := range usage.Images {
if item == nil {
continue
}
for _, item := range usage.Images.Items {
result.Images = append(result.Images, ImageUsage{
ID: item.ID, SizeBytes: item.Size, Containers: item.Containers,
CreatedAt: time.Unix(item.Created, 0).UTC(),
})
}
for _, item := range usage.Containers {
if item == nil {
continue
}
for _, item := range usage.Containers.Items {
result.Containers = append(result.Containers, ContainerUsage{
ID: item.ID, WritableBytes: item.SizeRw, Labels: item.Labels,
})
@@ -91,32 +83,34 @@ func (c *Client) DiskUsage(ctx context.Context) (DiskUsage, error) {
}
func (c *Client) PruneBuildCache(ctx context.Context, options BuildCachePruneOptions) (PruneResult, error) {
pruneFilters := filters.NewArgs(filters.Arg("until", unixFilter(options.UnusedBefore)))
report, err := c.storageClient().BuildCachePrune(ctx, build.CachePruneOptions{
All: true, ReservedSpace: options.KeepBytes, KeepStorage: options.KeepBytes, Filters: pruneFilters,
// The client translates ReservedSpace into the legacy "keep-storage"
// parameter when talking to daemons on API <= 1.47.
result, err := c.storageClient().BuildCachePrune(ctx, client.BuildCachePruneOptions{
All: true,
ReservedSpace: options.KeepBytes,
Filters: make(client.Filters).Add("until", unixFilter(options.UnusedBefore)),
})
if err != nil {
return PruneResult{}, fmt.Errorf("prune Docker build cache: %w", err)
}
if report == nil {
return PruneResult{}, nil
}
return PruneResult{
Deleted: len(report.CachesDeleted), BytesReclaimed: reclaimedBytes(report.SpaceReclaimed),
Deleted: len(result.Report.CachesDeleted),
BytesReclaimed: reclaimedBytes(result.Report.SpaceReclaimed),
}, nil
}
func (c *Client) PruneUnusedImages(ctx context.Context, unusedBefore time.Time) (PruneResult, error) {
pruneFilters := filters.NewArgs(
filters.Arg("dangling", "false"),
filters.Arg("until", unixFilter(unusedBefore)),
)
report, err := c.storageClient().ImagesPrune(ctx, pruneFilters)
result, err := c.storageClient().ImagePrune(ctx, client.ImagePruneOptions{
Filters: make(client.Filters).
Add("dangling", "false").
Add("until", unixFilter(unusedBefore)),
})
if err != nil {
return PruneResult{}, fmt.Errorf("prune unused Docker images: %w", err)
}
return PruneResult{
Deleted: len(report.ImagesDeleted), BytesReclaimed: reclaimedBytes(report.SpaceReclaimed),
Deleted: len(result.Report.ImagesDeleted),
BytesReclaimed: reclaimedBytes(result.Report.SpaceReclaimed),
}, nil
}
@@ -7,12 +7,11 @@ import (
"testing"
"time"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/build"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/kandev/kandev/internal/common/logger"
"github.com/moby/moby/api/types/build"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image"
"github.com/moby/moby/client"
"go.uber.org/zap"
)
@@ -63,21 +62,27 @@ func TestStorageResultJSONUsesSnakeCase(t *testing.T) {
func TestDiskUsageMapsTypedSDKResponse(t *testing.T) {
created := time.Unix(100, 0)
lastUsed := time.Unix(200, 0)
sdk := &fakeStorageAPI{usage: dockertypes.DiskUsage{
LayersSize: 500,
Images: []*image.Summary{{ID: "image-1", Size: 300, Containers: 0, Created: created.Unix()}},
Containers: []*container.Summary{
{ID: "managed", SizeRw: 75, Labels: map[string]string{"kandev.managed": "true"}},
{ID: "unrelated", SizeRw: 125, Labels: map[string]string{"owner": "user"}},
sdk := &fakeStorageAPI{usage: client.DiskUsageResult{
Images: client.ImagesDiskUsage{
TotalSize: 500,
Items: []image.Summary{{ID: "image-1", Size: 300, Containers: 0, Created: created.Unix()}},
},
BuildCache: []*build.CacheRecord{
{ID: "cache-1", Size: 100},
{ID: "cache-2", Size: 200, LastUsedAt: &lastUsed},
Containers: client.ContainersDiskUsage{
Items: []container.Summary{
{ID: "managed", SizeRw: 75, Labels: map[string]string{"kandev.managed": "true"}},
{ID: "unrelated", SizeRw: 125, Labels: map[string]string{"owner": "user"}},
},
},
BuildCache: client.BuildCacheDiskUsage{
Items: []build.CacheRecord{
{ID: "cache-1", Size: 100},
{ID: "cache-2", Size: 200, LastUsedAt: &lastUsed},
},
},
}}
client := &Client{storage: sdk}
dockerClient := &Client{storage: sdk}
got, err := client.DiskUsage(context.Background())
got, err := dockerClient.DiskUsage(context.Background())
if err != nil {
t.Fatalf("DiskUsage: %v", err)
}
@@ -87,20 +92,23 @@ func TestDiskUsageMapsTypedSDKResponse(t *testing.T) {
got.Containers[0].Labels["kandev.managed"] != "true" {
t.Fatalf("disk usage = %#v", got)
}
wantTypes := []dockertypes.DiskUsageObject{
dockertypes.ContainerObject, dockertypes.ImageObject, dockertypes.BuildCacheObject,
// Verbose is what makes the daemon return the per-object Items above.
wantOptions := client.DiskUsageOptions{
Containers: true, Images: true, BuildCache: true, Verbose: true,
}
if !reflect.DeepEqual(sdk.usageOptions.Types, wantTypes) {
t.Fatalf("disk usage types = %#v, want %#v", sdk.usageOptions.Types, wantTypes)
if !reflect.DeepEqual(sdk.usageOptions, wantOptions) {
t.Fatalf("disk usage options = %#v, want %#v", sdk.usageOptions, wantOptions)
}
}
func TestPruneBuildCacheUsesAgeAndReservedSpaceFilters(t *testing.T) {
sdk := &fakeStorageAPI{buildReport: &build.CachePruneReport{CachesDeleted: []string{"one"}, SpaceReclaimed: 42}}
client := &Client{storage: sdk}
sdk := &fakeStorageAPI{buildResult: client.BuildCachePruneResult{
Report: build.CachePruneReport{CachesDeleted: []string{"one"}, SpaceReclaimed: 42},
}}
dockerClient := &Client{storage: sdk}
cutoff := time.Unix(1234, 0).UTC()
got, err := client.PruneBuildCache(context.Background(), BuildCachePruneOptions{
got, err := dockerClient.PruneBuildCache(context.Background(), BuildCachePruneOptions{
KeepBytes: 1024, UnusedBefore: cutoff,
})
if err != nil {
@@ -110,27 +118,29 @@ func TestPruneBuildCacheUsesAgeAndReservedSpaceFilters(t *testing.T) {
t.Fatalf("prune result = %#v", got)
}
if !sdk.buildOptions.All || sdk.buildOptions.ReservedSpace != 1024 ||
sdk.buildOptions.KeepStorage != 1024 ||
!sdk.buildOptions.Filters.ExactMatch("until", "1234") {
!sdk.buildOptions.Filters["until"]["1234"] {
t.Fatalf("build prune options = %#v", sdk.buildOptions)
}
}
func TestPruneUnusedImagesUsesAllUnusedAndAgeFilters(t *testing.T) {
sdk := &fakeStorageAPI{imageReport: image.PruneReport{
ImagesDeleted: []image.DeleteResponse{{Deleted: "image-1"}}, SpaceReclaimed: 84,
sdk := &fakeStorageAPI{imageResult: client.ImagePruneResult{
Report: image.PruneReport{
ImagesDeleted: []image.DeleteResponse{{Deleted: "image-1"}}, SpaceReclaimed: 84,
},
}}
client := &Client{storage: sdk}
dockerClient := &Client{storage: sdk}
got, err := client.PruneUnusedImages(context.Background(), time.Unix(5678, 0).UTC())
got, err := dockerClient.PruneUnusedImages(context.Background(), time.Unix(5678, 0).UTC())
if err != nil {
t.Fatalf("PruneUnusedImages: %v", err)
}
if got.Deleted != 1 || got.BytesReclaimed != 84 {
t.Fatalf("prune result = %#v", got)
}
if !sdk.imageFilters.ExactMatch("dangling", "false") || !sdk.imageFilters.ExactMatch("until", "5678") {
t.Fatalf("image prune filters = %#v", sdk.imageFilters)
if !sdk.imageOptions.Filters["dangling"]["false"] ||
!sdk.imageOptions.Filters["until"]["5678"] {
t.Fatalf("image prune filters = %#v", sdk.imageOptions.Filters)
}
}
@@ -140,9 +150,9 @@ func TestRemoveContainerRemovesAttachedVolumesWithoutGlobalPrune(t *testing.T) {
if err != nil {
t.Fatalf("new logger: %v", err)
}
client := &Client{remover: remover, logger: log}
dockerClient := &Client{remover: remover, logger: log}
if err := client.RemoveContainer(context.Background(), "container-1", false); err != nil {
if err := dockerClient.RemoveContainer(context.Background(), "container-1", false); err != nil {
t.Fatalf("RemoveContainer: %v", err)
}
if remover.id != "container-1" || remover.options.Force || !remover.options.RemoveVolumes {
@@ -151,39 +161,41 @@ func TestRemoveContainerRemovesAttachedVolumesWithoutGlobalPrune(t *testing.T) {
}
type fakeStorageAPI struct {
usage dockertypes.DiskUsage
usage client.DiskUsageResult
usageErr error
usageOptions dockertypes.DiskUsageOptions
buildOptions build.CachePruneOptions
buildReport *build.CachePruneReport
usageOptions client.DiskUsageOptions
buildOptions client.BuildCachePruneOptions
buildResult client.BuildCachePruneResult
buildErr error
imageFilters filters.Args
imageReport image.PruneReport
imageOptions client.ImagePruneOptions
imageResult client.ImagePruneResult
imageErr error
}
func (f *fakeStorageAPI) DiskUsage(_ context.Context, options dockertypes.DiskUsageOptions) (dockertypes.DiskUsage, error) {
func (f *fakeStorageAPI) DiskUsage(_ context.Context, options client.DiskUsageOptions) (client.DiskUsageResult, error) {
f.usageOptions = options
return f.usage, f.usageErr
}
func (f *fakeStorageAPI) BuildCachePrune(_ context.Context, options build.CachePruneOptions) (*build.CachePruneReport, error) {
func (f *fakeStorageAPI) BuildCachePrune(_ context.Context, options client.BuildCachePruneOptions) (client.BuildCachePruneResult, error) {
f.buildOptions = options
return f.buildReport, f.buildErr
return f.buildResult, f.buildErr
}
func (f *fakeStorageAPI) ImagesPrune(_ context.Context, pruneFilters filters.Args) (image.PruneReport, error) {
f.imageFilters = pruneFilters
return f.imageReport, f.imageErr
func (f *fakeStorageAPI) ImagePrune(_ context.Context, options client.ImagePruneOptions) (client.ImagePruneResult, error) {
f.imageOptions = options
return f.imageResult, f.imageErr
}
type fakeContainerRemover struct {
id string
options container.RemoveOptions
options client.ContainerRemoveOptions
}
func (f *fakeContainerRemover) ContainerRemove(_ context.Context, id string, options container.RemoveOptions) error {
func (f *fakeContainerRemover) ContainerRemove(
_ context.Context, id string, options client.ContainerRemoveOptions,
) (client.ContainerRemoveResult, error) {
f.id = id
f.options = options
return nil
return client.ContainerRemoveResult{}, nil
}
+13 -3
View File
@@ -19,8 +19,18 @@ ignore = [
# Fix: upgrade rand to >= 0.8.6 (semver-breaking; requires coordinating dependents).
"RUSTSEC-2026-0097",
# glib 0.18.5 — unsound VariantStrIter::impl_get, Linux-only via webkit2gtk chain.
# No known exploit in the wild; will resolve when Tauri migrates to webkit2gtk-4.1
# which pulls glib >= 0.20.0.
# glib 0.18.5 — unsound VariantStrIter::impl_get, Linux-only via the gtk 0.18
# chain that Tauri v2 uses for its Linux backend.
#
# The advisory's fix version (0.20.0) is not reachable: gtk-rs/gtk3-rs, which
# publishes gtk/gdk/webkit2gtk, was archived in March 2024 and will never
# release a version depending on glib >= 0.20. Tauri closed its own tracking
# issue as not_planned and targets the GTK4 migration at Tauri v3
# (tauri-apps/tauri#12564).
#
# A glib 0.18.6 patch release would fix this for us automatically, since
# gtk 0.18.2 depends on glib with a caret requirement. Backport submitted
# upstream: gtk-rs/gtk-rs-core#2009, release request #2010. Drop this entry
# once 0.18.6 ships.
"RUSTSEC-2024-0429",
]