From 767cc89e6cff9f3c097b56fa6632fe89888d812f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 24 Jul 2026 23:33:41 +0100 Subject: [PATCH] fix(backend): move Docker client to the maintained moby client module (#1935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/backend/go.mod | 15 +- apps/backend/go.sum | 40 +-- .../internal/agent/docker/activity_test.go | 8 +- apps/backend/internal/agent/docker/client.go | 246 ++++++++++++------ .../internal/agent/docker/client_test.go | 88 +++++-- apps/backend/internal/agent/docker/storage.go | 72 +++-- .../internal/agent/docker/storage_test.go | 108 ++++---- apps/desktop/src-tauri/.cargo/audit.toml | 16 +- 8 files changed, 360 insertions(+), 233 deletions(-) diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 6c02033769..0cc43cd7c6 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -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 diff --git a/apps/backend/go.sum b/apps/backend/go.sum index c450ca9345..f364fe03f6 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -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= diff --git a/apps/backend/internal/agent/docker/activity_test.go b/apps/backend/internal/agent/docker/activity_test.go index ec9fb80107..9de66f48ea 100644 --- a/apps/backend/internal/agent/docker/activity_test.go +++ b/apps/backend/internal/agent/docker/activity_test.go @@ -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 } diff --git a/apps/backend/internal/agent/docker/client.go b/apps/backend/internal/agent/docker/client.go index 47361cc9f9..a1021e3df1 100644 --- a/apps/backend/internal/agent/docker/client.go +++ b/apps/backend/internal/agent/docker/client.go @@ -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, diff --git a/apps/backend/internal/agent/docker/client_test.go b/apps/backend/internal/agent/docker/client_test.go index d2d86476b9..349ccf4f15 100644 --- a/apps/backend/internal/agent/docker/client_test.go +++ b/apps/backend/internal/agent/docker/client_test.go @@ -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) + } + } +} diff --git a/apps/backend/internal/agent/docker/storage.go b/apps/backend/internal/agent/docker/storage.go index 2e656e8079..e5fdff4f12 100644 --- a/apps/backend/internal/agent/docker/storage.go +++ b/apps/backend/internal/agent/docker/storage.go @@ -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 } diff --git a/apps/backend/internal/agent/docker/storage_test.go b/apps/backend/internal/agent/docker/storage_test.go index 9568db0a4c..702b087818 100644 --- a/apps/backend/internal/agent/docker/storage_test.go +++ b/apps/backend/internal/agent/docker/storage_test.go @@ -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 } diff --git a/apps/desktop/src-tauri/.cargo/audit.toml b/apps/desktop/src-tauri/.cargo/audit.toml index 459f717f23..74dc235712 100644 --- a/apps/desktop/src-tauri/.cargo/audit.toml +++ b/apps/desktop/src-tauri/.cargo/audit.toml @@ -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", ]