From 3e7d697ea2a6b323e310f224dc6956ab3a390cdb Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:34:27 +0100 Subject: [PATCH 01/10] feat: add buildkit connection helpers --- internal/cmd/compute/build/connhelpers.go | 11 ++++++++ .../cmd/compute/build/connhelpers_test.go | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 internal/cmd/compute/build/connhelpers.go create mode 100644 internal/cmd/compute/build/connhelpers_test.go diff --git a/internal/cmd/compute/build/connhelpers.go b/internal/cmd/compute/build/connhelpers.go new file mode 100644 index 00000000..62fe9442 --- /dev/null +++ b/internal/cmd/compute/build/connhelpers.go @@ -0,0 +1,11 @@ +package build + +// Connection helpers register their URL schemes (docker-container://, ssh://, +// ...) from init(); without these imports BuildKit dials them as raw TCP. +import ( + _ "github.com/moby/buildkit/client/connhelper/dockercontainer" + _ "github.com/moby/buildkit/client/connhelper/kubepod" + _ "github.com/moby/buildkit/client/connhelper/nerdctlcontainer" + _ "github.com/moby/buildkit/client/connhelper/podmancontainer" + _ "github.com/moby/buildkit/client/connhelper/ssh" +) diff --git a/internal/cmd/compute/build/connhelpers_test.go b/internal/cmd/compute/build/connhelpers_test.go new file mode 100644 index 00000000..236fe85b --- /dev/null +++ b/internal/cmd/compute/build/connhelpers_test.go @@ -0,0 +1,25 @@ +package build + +import ( + "testing" + + "github.com/moby/buildkit/client/connhelper" +) + +func TestBuildKitConnectionHelpersRegistered(t *testing.T) { + for _, addr := range []string{ + "docker-container://buildx_buildkit_builder0", + "podman-container://buildkitd", + "nerdctl-container://buildkitd", + "kube-pod://buildkitd", + "ssh://user@example.com", + } { + helper, err := connhelper.GetConnectionHelper(addr) + if err != nil { + t.Fatalf("%s: %v", addr, err) + } + if helper == nil { + t.Fatalf("%s: no connection helper registered", addr) + } + } +} From 17280dd95c52d4b313a637383ef2083e1403662e Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:46:14 +0100 Subject: [PATCH 02/10] feat: find buildkit the way the docker CLI does, and explain failures --- go.mod | 15 +- go.sum | 23 ++ internal/cmd/compute/build/analysis_test.go | 5 +- internal/cmd/compute/build/buildkit.go | 65 +-- internal/cmd/compute/build/command.go | 11 + internal/cmd/compute/build/connect.go | 428 ++++++++++++++++++++ internal/cmd/compute/build/connect_test.go | 266 ++++++++++++ internal/cmd/compute/build/pipeline.go | 3 +- internal/cmd/compute/build/rootfs.go | 28 +- 9 files changed, 766 insertions(+), 78 deletions(-) create mode 100644 internal/cmd/compute/build/connect.go create mode 100644 internal/cmd/compute/build/connect_test.go diff --git a/go.mod b/go.mod index 3bad6499..bb2672fd 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( require ( github.com/distribution/reference v0.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/moby/moby/client v0.5.0 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/tmc/go-iroh v0.2.2 @@ -36,8 +37,10 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/containerd/console v1.0.5 // indirect github.com/containerd/containerd/api v1.10.0 // indirect @@ -49,9 +52,11 @@ require ( github.com/containerd/platforms v1.0.0-rc.4 // indirect github.com/containerd/ttrpc v1.2.8 // indirect github.com/containerd/typeurl/v2 v2.3.0 // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/fvbommel/sortorder v1.2.0 // indirect github.com/go-openapi/swag/cmdutils v0.26.0 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect github.com/go-openapi/swag/fileutils v0.26.0 // indirect @@ -66,6 +71,7 @@ require ( github.com/gofrs/flock v0.13.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect @@ -74,12 +80,16 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect + github.com/moby/moby/api v1.55.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect github.com/moby/sys/signal v0.7.1 // indirect + github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect @@ -96,7 +106,8 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 // indirect - gotest.tools/v3 v3.5.2 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect k8s.io/streaming v0.36.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) @@ -137,7 +148,7 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/spf13/cobra v1.10.2 - github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/pflag v1.0.10 github.com/x448/float16 v0.8.4 // indirect go.miloapis.com/service-catalog v0.10.0 go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index b8a4b1aa..95fd6935 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= @@ -82,12 +84,16 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 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/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= 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-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= 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/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= @@ -104,6 +110,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fvbommel/sortorder v1.2.0 h1:TRIiRiGX+djh3Yf4FVxmWmAcYfIr5dH0NbzJWOSAWZk= +github.com/fvbommel/sortorder v1.2.0/go.mod h1:LbhO04ijZIeUuvz9B9BkI/qYrpZZEn1gWhxv4QjUKVs= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -186,6 +194,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= @@ -220,6 +230,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/moby/buildkit v0.31.1 h1:j3p55abBl4kiXXPZgYX+6zWgB2aefqHXoPown12fIzU= @@ -228,6 +240,10 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +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/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/policy-helpers v0.0.0-20260612073044-d5411a945cfc h1:dvhPFj1niuMP3CBCjhiZWJQr//+w1LOA8cHclFJnNe0= @@ -377,6 +393,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= @@ -385,6 +403,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3P go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= @@ -418,6 +438,7 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -472,6 +493,8 @@ lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI= mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= diff --git a/internal/cmd/compute/build/analysis_test.go b/internal/cmd/compute/build/analysis_test.go index dbe51cf6..81fd43c6 100644 --- a/internal/cmd/compute/build/analysis_test.go +++ b/internal/cmd/compute/build/analysis_test.go @@ -1003,10 +1003,7 @@ func TestRootfsBuildError(t *testing.T) { }{ {name: "keeps Dockerfile build failures as-is", input: `process "/bin/sh -c go build ./..." did not complete successfully: exit code: 1`, - wantIn: "go build", wantNotIn: "Docker is not running"}, - {name: "simplifies Docker availability failures", - input: "could not connect to buildkit: could not start ephemeral BuildKit container", - want: "building root filesystem: Docker is not running or is not accessible"}, + wantIn: "go build"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/compute/build/buildkit.go b/internal/cmd/compute/build/buildkit.go index 9c63d1c9..014989f3 100644 --- a/internal/cmd/compute/build/buildkit.go +++ b/internal/cmd/compute/build/buildkit.go @@ -12,8 +12,6 @@ import ( "strings" "github.com/docker/cli/cli/config" - dockerclient "github.com/docker/docker/client" - dockerbuildkit "github.com/docker/docker/client/buildkit" erofs "github.com/erofs/go-erofs" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/layout" @@ -21,7 +19,6 @@ import ( bkclient "github.com/moby/buildkit/client" "github.com/moby/buildkit/session" "github.com/moby/buildkit/session/auth/authprovider" - bkappdefaults "github.com/moby/buildkit/util/appdefaults" "github.com/moby/buildkit/util/progress/progressui" "github.com/tonistiigi/fsutil" "golang.org/x/sync/errgroup" @@ -44,6 +41,7 @@ type buildRequest struct { } type dockerfileFinalStageRequest struct { + Address string ContextDir string Dockerfile string Target string @@ -55,6 +53,7 @@ type dockerfileFinalStageRequest struct { func buildDockerfileFinalStage(ctx context.Context, req dockerfileFinalStageRequest) (packagingArtifact, error) { if err := buildDockerfileExports(ctx, buildRequest{ + Address: req.Address, ContextDir: req.ContextDir, Dockerfile: req.Dockerfile, Target: req.Target, @@ -282,7 +281,7 @@ func firstImageFromIndex(idx v1.ImageIndex) (v1.Image, error) { } func buildDockerfileExports(ctx context.Context, req buildRequest, rootfsTarPath string, ociTarPath string) error { - bk, cleanup, err := connectBuildkit(ctx, req.Address) + bk, cleanup, endpoint, err := connectBuildkit(ctx, req.Address) if err != nil { return err } @@ -290,6 +289,9 @@ func buildDockerfileExports(ctx context.Context, req buildRequest, rootfsTarPath if cleanup != nil { defer cleanup() } + if req.progressOut != nil { + fmt.Fprintf(req.progressOut, "Building with %s\n", endpoint) + } rootfsTar, err := os.OpenFile(rootfsTarPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { @@ -357,61 +359,6 @@ func closeExportFile(f *os.File) error { return err } -func connectBuildkit(ctx context.Context, address string) (*bkclient.Client, func(), error) { - if address == "" { - address = os.Getenv("BUILDKIT_HOST") - } - if address != "" { - c, err := bkclient.New(ctx, address) - if err != nil { - return nil, nil, fmt.Errorf("creating configured BuildKit client: %w", err) - } - if _, err := c.Info(ctx); err != nil { - _ = c.Close() - return nil, nil, fmt.Errorf("connecting to configured BuildKit client: %w", err) - } - return c, nil, nil - } - - if c, err := bkclient.New(ctx, bkappdefaults.Address); err == nil { - if _, err := c.Info(ctx); err == nil { - return c, nil, nil - } - _ = c.Close() - } - - c, cleanup, err := connectDockerBuildkit(ctx) - if err == nil && c != nil { - return c, cleanup, nil - } - if err != nil { - return nil, nil, err - } - return nil, nil, fmt.Errorf("could not connect to BuildKit: set BUILDKIT_HOST, start buildkitd, or enable Docker's BuildKit backend") -} - -func connectDockerBuildkit(ctx context.Context) (*bkclient.Client, func(), error) { - docker, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation()) - if err != nil { - return nil, nil, nil - } - if _, err := docker.ServerVersion(ctx); err != nil { - _ = docker.Close() - return nil, nil, nil - } - c, err := bkclient.New(ctx, "", dockerbuildkit.ClientOpts(docker)...) - if err != nil { - _ = docker.Close() - return nil, nil, fmt.Errorf("creating Docker BuildKit client: %w", err) - } - if _, err := c.Info(ctx); err != nil { - _ = c.Close() - _ = docker.Close() - return nil, nil, nil - } - return c, func() { _ = docker.Close() }, nil -} - func buildSolveOpt(req buildRequest, output io.WriteCloser) (*bkclient.SolveOpt, error) { contextMount, err := fsutil.NewFS(req.ContextDir) if err != nil { diff --git a/internal/cmd/compute/build/command.go b/internal/cmd/compute/build/command.go index a58e9692..7541cc8e 100644 --- a/internal/cmd/compute/build/command.go +++ b/internal/cmd/compute/build/command.go @@ -13,6 +13,7 @@ type Options struct { Analyze bool BuildArgs []string BuildTarget string + BuildkitHost string ContextDir string Dockerfile string DockerfileExplicit bool @@ -63,6 +64,12 @@ shared libraries that were not copied into the final image. The optional --fix flag applies safe exact-line fixes to the selected Dockerfile, then rebuilds from that file. +Builds run on BuildKit. By default, build uses the BuildKit built into your +container engine (Docker Desktop, OrbStack, Colima, Docker Engine, ...), honoring +DOCKER_HOST and DOCKER_CONTEXT the same way the docker CLI does. Use +--buildkit-host (or BUILDKIT_HOST) to build with a different BuildKit, such as a +buildx builder (docker-container://NAME) or a remote one (tcp://HOST:PORT). + Advanced users can provide a Kraftfile with --kraftfile (or by placing one in the build context) to delegate the entire build to the unikraft CLI instead, which must be installed separately. Most projects do not need one.`, @@ -91,6 +98,9 @@ datumctl compute build --target production . # Check the built app before packaging it datumctl compute build --analyze . +# Build with a specific BuildKit +datumctl compute build --buildkit-host docker-container://buildx_buildkit_default . + # Advanced: delegate to unikraft build using an existing Kraftfile datumctl compute build --kraftfile ./Kraftfile . `, @@ -107,6 +117,7 @@ datumctl compute build --kraftfile ./Kraftfile . cmd.Flags().BoolVar(&opts.Analyze, "analyze", false, "Analyze the Dockerfile output for Compute compatibility before packaging") cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil, "Set build-time variables (KEY=VALUE or KEY to inherit from env)") cmd.Flags().StringVar(&opts.BuildTarget, "target", "", "Dockerfile stage to build") + cmd.Flags().StringVar(&opts.BuildkitHost, "buildkit-host", "", "BuildKit address to build with (default: $BUILDKIT_HOST, then your container engine's BuildKit)") cmd.Flags().StringVarP(&opts.Dockerfile, "file", "f", "Dockerfile", "Path to Dockerfile") cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Apply safe exact-line fixes to the selected Dockerfile and rebuild (implies --analyze)") cmd.Flags().StringVar(&opts.Kraftfile, "kraftfile", "", "Advanced: delegate the build to the unikraft CLI using this Kraftfile") diff --git a/internal/cmd/compute/build/connect.go b/internal/cmd/compute/build/connect.go new file mode 100644 index 00000000..58e04f1e --- /dev/null +++ b/internal/cmd/compute/build/connect.go @@ -0,0 +1,428 @@ +package build + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/config" + cliflags "github.com/docker/cli/cli/flags" + dockerbuildkit "github.com/docker/docker/client/buildkit" + bkclient "github.com/moby/buildkit/client" + bkappdefaults "github.com/moby/buildkit/util/appdefaults" + mobyclient "github.com/moby/moby/client" + "github.com/spf13/pflag" +) + +const ( + buildkitHostFlag = "--buildkit-host" + dockerDesktop = "Docker Desktop" + buildkitHostEnv = "BUILDKIT_HOST" + + whyBuildkit = "Datum Compute runs your app in a lightweight virtual machine, built from your\n" + + "Dockerfile. datumctl uses BuildKit as part of the build process." + + // minEngineMajor is the first Docker release whose BuildKit answers the + // Info call datumctl makes on connect. + minEngineMajor = 23 +) + +// connectError's cause is only shown with --verbose. +type connectError struct { + message string + cause error +} + +func (e *connectError) Error() string { return e.message } +func (e *connectError) Unwrap() error { return e.cause } + +func paragraphs(p ...string) string { return strings.Join(p, "\n\n") } + +// connectBuildkit returns a BuildKit client, a cleanup func (possibly nil), +// and a short name for what it connected to. An explicit address +// (--buildkit-host, then BUILDKIT_HOST) is used as-is; otherwise a running +// standalone buildkitd wins, then the container engine's built-in BuildKit. +func connectBuildkit(ctx context.Context, address string) (*bkclient.Client, func(), string, error) { + source := buildkitHostFlag + if address == "" { + address, source = os.Getenv(buildkitHostEnv), buildkitHostEnv + } + if address != "" { + c, err := dialBuildkit(ctx, address) + if err != nil { + return nil, nil, "", explicitBuildkitError(address, source, err) + } + return c, nil, "BuildKit at " + address, nil + } + + // Most machines have no standalone buildkitd, so it's only tried when its + // socket exists. + if _, err := os.Stat(strings.TrimPrefix(bkappdefaults.Address, "unix://")); err == nil { + if c, err := dialBuildkit(ctx, bkappdefaults.Address); err == nil { + return c, nil, "buildkitd at " + bkappdefaults.Address, nil + } + } + + return connectEngineBuildkit(ctx) +} + +func dialBuildkit(ctx context.Context, address string) (*bkclient.Client, error) { + c, err := bkclient.New(ctx, address) + if err != nil { + return nil, err + } + if _, err := c.Info(ctx); err != nil { + _ = c.Close() + return nil, err + } + return c, nil +} + +// engineSelection records which setting chose the engine endpoint, so +// errors can name it. +type engineSelection struct { + context string // empty for DOCKER_HOST and the default context + setBy string // DOCKER_HOST, DOCKER_CONTEXT, configFileSetting, or empty +} + +const configFileSetting = "config" + +func currentEngineSelection() engineSelection { + if os.Getenv(mobyclient.EnvOverrideHost) != "" { + return engineSelection{setBy: mobyclient.EnvOverrideHost} + } + if name := os.Getenv(command.EnvOverrideContext); name != "" && name != command.DefaultContextName { + return engineSelection{context: name, setBy: command.EnvOverrideContext} + } + if name := config.LoadDefaultConfigFile(io.Discard).CurrentContext; name != "" && name != command.DefaultContextName { + return engineSelection{context: name, setBy: configFileSetting} + } + return engineSelection{} +} + +func engineName(sel engineSelection, host string) string { + switch { + case sel.context == "orbstack" || strings.Contains(host, "/.orbstack/"): + return "OrbStack" + case strings.HasPrefix(sel.context, "colima") || strings.Contains(host, "/.colima/"): + return "Colima" + case sel.context == "rancher-desktop" || strings.Contains(host, "/.rd/"): + return "Rancher Desktop" + case sel.context == "desktop-linux" || strings.Contains(host, "/.docker/run/"): + return dockerDesktop + default: + return "" + } +} + +func connectEngineBuildkit(ctx context.Context) (*bkclient.Client, func(), string, error) { + sel := currentEngineSelection() + engine, err := newEngineClient() + if err != nil { + return nil, nil, "", engineSetupError(sel, err) + } + host := engine.DaemonHost() + if err := probeEngine(ctx, engine); err != nil { + _ = engine.Close() + return nil, nil, "", engineUnreachableError(sel, host, runtime.GOOS, err) + } + c, err := bkclient.New(ctx, "", dockerbuildkit.ClientOpts(engine)...) + if err == nil { + if _, err = c.Info(ctx); err != nil { + _ = c.Close() + } + } + if err != nil { + v, _ := engine.ServerVersion(ctx, mobyclient.ServerVersionOptions{}) + _ = engine.Close() + return nil, nil, "", noEngineBuildkitError(sel, host, v, runtime.GOOS, err) + } + + name := engineName(sel, host) + switch { + case name != "": + case sel.context == "" && sel.setBy == "": + name = "Docker" + case sel.context != "": + name = fmt.Sprintf("Docker context %q", sel.context) + default: + name = "the container engine at " + host + } + return c, func() { _ = engine.Close() }, name, nil +} + +// newEngineClient resolves the endpoint exactly as the docker CLI does. +func newEngineClient() (mobyclient.APIClient, error) { + opts := cliflags.NewClientOptions() + flags := pflag.NewFlagSet("docker", pflag.ContinueOnError) + opts.InstallFlags(flags) + opts.SetDefaultOptions(flags) + return command.NewAPIClientFromFlags(opts, config.LoadDefaultConfigFile(io.Discard)) +} + +// probeEngine checks that the engine answers. Unix and TCP endpoints are +// dialed directly first, because the client folds every connection failure +// into one message and the user needs to know which one it was. +func probeEngine(ctx context.Context, engine mobyclient.APIClient) error { + if network, addr, ok := dialTarget(engine.DaemonHost()); ok { + conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, addr) + if err != nil { + return err + } + _ = conn.Close() + } + _, err := engine.Ping(ctx, mobyclient.PingOptions{}) + return err +} + +func dialTarget(host string) (network, addr string, ok bool) { + u, err := url.Parse(host) + if err != nil { + return "", "", false + } + switch u.Scheme { + case "unix": + return "unix", u.Path, true + case "tcp": + return "tcp", u.Host, true + default: + return "", "", false + } +} + +func installOptions(goos string) string { + // Standalone BuildKit only runs natively on Linux-like hosts. + return formatInstallOptions(engineInstallOptions(goos, goos != "darwin" && goos != "windows")) +} + +type installOption struct{ name, link string } + +func engineInstallOptions(goos string, withBuildKit bool) []installOption { + type option = installOption + var opts []option + switch goos { + case "darwin": + opts = []option{ + {dockerDesktop, "https://docs.docker.com/desktop/setup/install/mac-install/"}, + {"OrbStack", "https://orbstack.dev/download"}, + {"Colima", "https://colima.run/docs/installation/"}, + } + case "linux": + opts = []option{ + {"Docker Engine", "https://docs.docker.com/engine/install/"}, + } + case "windows": + opts = []option{ + {dockerDesktop, "https://docs.docker.com/desktop/setup/install/windows-install/"}, + } + default: + opts = []option{ + {"Docker", "https://docs.docker.com/get-started/get-docker/"}, + } + } + if withBuildKit { + opts = append(opts, option{"BuildKit", "https://github.com/moby/buildkit#quick-start"}) + } + return opts +} + +func formatInstallOptions(opts []installOption) string { + lines := make([]string, len(opts)) + for i, o := range opts { + lines[i] = fmt.Sprintf(" %-16s %s", o.name, o.link) + } + return strings.Join(lines, "\n") +} + +func noBuildkitError(goos string, cause error) error { + return &connectError{ + message: paragraphs( + "no BuildKit is available, so your image can't be built.", + whyBuildkit, + "BuildKit comes with most container engines and can also run on its own.\n"+ + "Install and start one of these, then run this command again.", + installOptions(goos), + "Already running BuildKit somewhere else? Pass its address with "+buildkitHostFlag+".", + ), + cause: cause, + } +} + +func engineSetupError(sel engineSelection, err error) error { + if sel.context != "" && errors.As(err, new(interface{ NotFound() })) { + if sel.setBy == command.EnvOverrideContext { + return &connectError{ + message: paragraphs( + fmt.Sprintf("DOCKER_CONTEXT is set to %q, but there's no Docker context with that name.", sel.context), + "Unset DOCKER_CONTEXT to use your default container engine, then run this command again.", + ), + cause: err, + } + } + return &connectError{ + message: paragraphs( + fmt.Sprintf("your Docker config selects the context %q, but there's no context with that name.", sel.context), + fmt.Sprintf("Remove \"currentContext\" from %s\nto use your default container engine, then run this command again.", + displayPath(filepath.Join(config.Dir(), config.ConfigFileName))), + ), + cause: err, + } + } + return &connectError{ + message: paragraphs( + "your container engine settings couldn't be loaded, so your image can't be built.", + "Run with --verbose to see why.", + ), + cause: err, + } +} + +func engineUnreachableError(sel engineSelection, host, goos string, err error) error { + // os.ErrNotExist, not syscall.ENOENT, so a missing Windows named pipe + // (Docker Desktop not running) counts too. + notListening := errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) + if notListening && sel.context == "" && sel.setBy == "" { + return noBuildkitError(goos, err) + } + + subject := engineName(sel, host) + if subject == "" { + subject = "your container engine" + } + var origin string + switch { + case sel.context != "": + origin = fmt.Sprintf("Your Docker context %q points at %s", sel.context, host) + case sel.setBy != "": + origin = fmt.Sprintf("%s points at %s", sel.setBy, host) + default: + origin = "datumctl looked for it at " + host + } + + switch { + case notListening: + fix := "Start " + subject + ", then run this command again." + if sel.setBy == mobyclient.EnvOverrideHost { + fix = "Start it, or unset DOCKER_HOST to use your default container engine,\nthen run this command again." + } + return &connectError{ + message: paragraphs( + subject+" isn't running, so your image can't be built.", + origin+", but nothing is listening there.\n"+fix, + ), + cause: err, + } + case errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM): + return &connectError{ + message: paragraphs( + fmt.Sprintf("your user can't access %s (permission denied on %s), so your image can't be built.", subject, host), + "Add yourself to the docker group, then log out and back in:\n sudo usermod -aG docker $USER", + ), + cause: err, + } + default: + return &connectError{ + message: paragraphs( + fmt.Sprintf("couldn't connect to %s at %s, so your image can't be built.", subject, host), + "Check that it's running and reachable, then run this command again.\nRun with --verbose for details.", + ), + cause: err, + } + } +} + +// podmanEngineComponent is how Podman names itself in its Docker-compatible +// /version response (pkg/api/handlers/compat/version.go); Platform.Name only +// carries the OS and distro. +const podmanEngineComponent = "Podman Engine" + +func isPodman(v mobyclient.ServerVersionResult) bool { + for _, c := range v.Components { + if c.Name == podmanEngineComponent { + return true + } + } + return false +} + +func noEngineBuildkitError(sel engineSelection, host string, v mobyclient.ServerVersionResult, goos string, err error) error { + // Podman's Docker-compatible API has no BuildKit endpoints and builds with + // Buildah instead, so updating it won't help; BuildKit can still run as a + // Podman container. See https://github.com/containers/podman/issues/17836. + if isPodman(v) { + return &userError{ + message: paragraphs( + "Podman doesn't include BuildKit, so your image can't be built.", + whyBuildkit, + "Run BuildKit in a Podman container and pass it with\n"+ + buildkitHostFlag+" podman-container://, or install a container\n"+ + "engine that includes BuildKit:", + formatInstallOptions(engineInstallOptions(goos, true)), + ), + cause: err, + } + } + + subject := engineName(sel, host) + if subject == "" { + subject = "your container engine" + } + version := v.Version + if major, err := strconv.Atoi(strings.Split(version, ".")[0]); err == nil && major < minEngineMajor { + return &connectError{ + message: paragraphs( + fmt.Sprintf("%s (version %s) is too old to build images.", subject, version), + whyBuildkit, + "Update it to a current version, or pass "+buildkitHostFlag+" to use a BuildKit\nyou run yourself.", + ), + cause: err, + } + } + return &connectError{ + message: paragraphs( + subject+" is running, but its BuildKit didn't respond, so your image can't be built.", + "Restart it, then run this command again. Run with --verbose for details.", + ), + cause: err, + } +} + +func explicitBuildkitError(address, source string, err error) error { + unset := "leave out " + buildkitHostFlag + if source == buildkitHostEnv { + unset = "unset " + buildkitHostEnv + } + reason := "it didn't respond. Run with --verbose for details." + msg := err.Error() + container, isContainer := strings.CutPrefix(address, "docker-container://") + container, _, _ = strings.Cut(container, "?") + switch { + case isContainer && strings.Contains(msg, "No such container"): + reason = fmt.Sprintf("there's no container named %q.", container) + case isContainer && strings.Contains(msg, "is not running"): + reason = fmt.Sprintf("the container %q is stopped.", container) + case isContainer && (strings.Contains(msg, "Cannot connect to the Docker daemon") || + strings.Contains(msg, "executable file not found")): + reason = "the container engine that runs it isn't available." + case strings.Contains(msg, "no such file or directory") || strings.Contains(msg, "connection refused"): + reason = "nothing is listening there." + } + return &connectError{ + message: paragraphs( + fmt.Sprintf("can't reach BuildKit at %s (from %s):\n%s", address, source, reason), + "Check the address, or "+unset+" to use your container engine's\nbuilt-in BuildKit.", + ), + cause: err, + } +} diff --git a/internal/cmd/compute/build/connect_test.go b/internal/cmd/compute/build/connect_test.go new file mode 100644 index 00000000..7dee4ce6 --- /dev/null +++ b/internal/cmd/compute/build/connect_test.go @@ -0,0 +1,266 @@ +package build + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + bkappdefaults "github.com/moby/buildkit/util/appdefaults" + "github.com/moby/moby/api/types/system" + mobyclient "github.com/moby/moby/client" +) + +// connectErr runs connectBuildkit and returns its user-facing error. +func connectErr(t *testing.T, address string) *connectError { + t.Helper() + _, _, _, err := connectBuildkit(context.Background(), address) + var ce *connectError + if !errors.As(err, &ce) { + t.Fatalf("expected a connectError, got %v", err) + return nil + } + return ce +} + +func assertContains(t *testing.T, got string, want ...string) { + t.Helper() + for _, w := range want { + if !strings.Contains(got, w) { + t.Errorf("message does not contain %q:\n%s", w, got) + } + } +} + +// shortTempDir returns a temp dir short enough for unix socket paths, which +// macOS caps at 104 bytes. +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "bk") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +func isolateEngineEnv(t *testing.T) { + t.Helper() + if _, err := os.Stat(strings.TrimPrefix(bkappdefaults.Address, "unix://")); err == nil { + t.Skip("a standalone buildkitd would be used before the container engine") + } + t.Setenv("BUILDKIT_HOST", "") + t.Setenv("DOCKER_HOST", "") + t.Setenv("DOCKER_CONTEXT", "") +} + +func TestConnectBuildkitEngineNotRunning(t *testing.T) { + isolateEngineEnv(t) + missing := "unix://" + filepath.Join(shortTempDir(t), "docker.sock") + t.Setenv("DOCKER_HOST", missing) + + ce := connectErr(t, "") + assertContains(t, ce.Error(), + "your container engine isn't running, so your image can't be built.", + "DOCKER_HOST points at "+missing+", but nothing is listening there.", + "unset DOCKER_HOST", + ) + if !errors.Is(ce, syscall.ENOENT) { + t.Errorf("expected the cause to be ENOENT, got %v", ce.cause) + } +} + +func TestConnectBuildkitEngineRefused(t *testing.T) { + isolateEngineEnv(t) + sock := filepath.Join(shortTempDir(t), "docker.sock") + l, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + l.(*net.UnixListener).SetUnlinkOnClose(false) + _ = l.Close() + t.Setenv("DOCKER_HOST", "unix://"+sock) + + assertContains(t, connectErr(t, "").Error(), "isn't running", "nothing is listening there") +} + +func TestConnectBuildkitEnginePermissionDenied(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores socket permissions") + } + isolateEngineEnv(t) + sock := filepath.Join(shortTempDir(t), "docker.sock") + l, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + defer l.Close() + if err := os.Chmod(sock, 0); err != nil { + t.Fatal(err) + } + t.Setenv("DOCKER_HOST", "unix://"+sock) + + assertContains(t, connectErr(t, "").Error(), + "your user can't access your container engine (permission denied on unix://"+sock+")", + "sudo usermod -aG docker $USER", + ) +} + +func TestConnectBuildkitUnknownContext(t *testing.T) { + isolateEngineEnv(t) + t.Setenv("DOCKER_CONTEXT", "nope") + + assertContains(t, connectErr(t, "").Error(), + `DOCKER_CONTEXT is set to "nope", but there's no Docker context with that name.`, + "Unset DOCKER_CONTEXT", + ) +} + +func TestConnectBuildkitExplicitAddress(t *testing.T) { + isolateEngineEnv(t) + missing := "unix://" + filepath.Join(shortTempDir(t), "buildkitd.sock") + + t.Run("flag", func(t *testing.T) { + assertContains(t, connectErr(t, missing).Error(), + "can't reach BuildKit at "+missing+" (from --buildkit-host):\nnothing is listening there.", + "leave out --buildkit-host", + ) + }) + t.Run("env", func(t *testing.T) { + t.Setenv("BUILDKIT_HOST", missing) + assertContains(t, connectErr(t, "").Error(), "(from BUILDKIT_HOST)", "unset BUILDKIT_HOST") + }) +} + +func TestExplicitBuildkitErrorReasons(t *testing.T) { + tests := []struct { + address, cause, want string + }{ + {"docker-container://builder0", "Error response from daemon: No such container: builder0", `there's no container named "builder0".`}, + {"docker-container://builder0?context=x", "container builder0 is not running", `the container "builder0" is stopped.`}, + {"docker-container://builder0", "Cannot connect to the Docker daemon at unix:///var/run/docker.sock", "the container engine that runs it isn't available."}, + {"tcp://127.0.0.1:1234", "dial tcp 127.0.0.1:1234: connect: connection refused", "nothing is listening there."}, + {"tcp://127.0.0.1:1234", "something unexpected", "it didn't respond. Run with --verbose for details."}, + } + for _, tt := range tests { + t.Run(tt.cause, func(t *testing.T) { + got := explicitBuildkitError(tt.address, buildkitHostFlag, errors.New(tt.cause)).Error() + assertContains(t, got, tt.want) + if strings.Contains(got, tt.cause) { + t.Errorf("message leaks the raw cause:\n%s", got) + } + }) + } +} + +func TestEngineUnreachableErrorDefaultEngineExplainsBuildkit(t *testing.T) { + err := fmt.Errorf("dial: %w", syscall.ENOENT) + for goos, wantLink := range map[string]string{ + "darwin": "https://orbstack.dev/download", + "linux": "https://docs.docker.com/engine/install/", + "windows": "https://docs.docker.com/desktop/setup/install/windows-install/", + } { + t.Run(goos, func(t *testing.T) { + got := engineUnreachableError(engineSelection{}, "unix:///var/run/docker.sock", goos, err).Error() + assertContains(t, got, "no BuildKit is available", whyBuildkit, wantLink, "--buildkit-host") + }) + } +} + +func TestEngineUnreachableErrorWindowsPipeMissing(t *testing.T) { + // Shaped like the Docker client's error when Docker Desktop's pipe + // doesn't exist: its connection error wrapping the dial's PathError. + err := fmt.Errorf("failed to connect to the docker API at npipe:////./pipe/docker_engine; "+ + "check if the path is correct and if the daemon is running: %w", + &os.PathError{Op: "open", Path: `\\.\pipe\docker_engine`, Err: os.ErrNotExist}) + + got := engineUnreachableError(engineSelection{}, "npipe:////./pipe/docker_engine", "windows", err).Error() + assertContains(t, got, + "no BuildKit is available", + "https://docs.docker.com/desktop/setup/install/windows-install/", + ) +} + +func TestEngineUnreachableErrorNamesEngine(t *testing.T) { + err := fmt.Errorf("dial: %w", syscall.ECONNREFUSED) + got := engineUnreachableError( + engineSelection{context: "colima", setBy: configFileSetting}, + "unix:///Users/me/.colima/default/docker.sock", "darwin", err, + ).Error() + assertContains(t, got, + "Colima isn't running, so your image can't be built.", + "Your Docker context \"colima\" points at unix:///Users/me/.colima/default/docker.sock, but nothing is listening there.", + "Start Colima, then run this command again.", + ) +} + +func TestNoEngineBuildkitError(t *testing.T) { + const host = "unix:///var/run/docker.sock" + docker := func(version string) mobyclient.ServerVersionResult { + return mobyclient.ServerVersionResult{ + Version: version, + Components: []system.ComponentVersion{{Name: "Engine", Version: version}}, + } + } + + old := noEngineBuildkitError(engineSelection{}, host, docker("20.10.24"), "", errors.New("x")).Error() + assertContains(t, old, "your container engine (version 20.10.24) is too old to build images.", "Update it") + + current := noEngineBuildkitError(engineSelection{}, host, docker("27.3.1"), "", errors.New("x")).Error() + assertContains(t, current, "its BuildKit didn't respond", "--verbose") + if strings.Contains(current, "too old") { + t.Errorf("current engine reported as too old:\n%s", current) + } +} + +func TestNoEngineBuildkitErrorPodman(t *testing.T) { + // Shaped like Podman's compat /version response + // (pkg/api/handlers/compat/version.go): its own version, and an engine + // component named "Podman Engine". + podman := mobyclient.ServerVersionResult{ + Platform: mobyclient.PlatformInfo{Name: "linux/arm64/fedora-40"}, + Version: "5.2.0", + Components: []system.ComponentVersion{ + {Name: "Podman Engine", Version: "5.2.0"}, + {Name: "Conmon", Version: "conmon version 2.1.12"}, + {Name: "OCI Runtime (crun)", Version: "crun version 1.17"}, + }, + } + + got := noEngineBuildkitError(engineSelection{}, "unix:///run/podman/podman.sock", podman, "darwin", errors.New("x")).Error() + assertContains(t, got, + "Podman doesn't include BuildKit, so your image can't be built.", + whyBuildkit, + "--buildkit-host podman-container://", + "https://orbstack.dev/download", + "https://github.com/moby/buildkit#quick-start", + ) + if strings.Contains(got, "too old") { + t.Errorf("Podman reported as too old:\n%s", got) + } +} + +func TestEngineName(t *testing.T) { + tests := []struct { + sel engineSelection + host string + want string + }{ + {engineSelection{context: "orbstack", setBy: configFileSetting}, "unix:///Users/me/.orbstack/run/docker.sock", "OrbStack"}, + {engineSelection{context: "colima-dev", setBy: configFileSetting}, "unix:///x", "Colima"}, + {engineSelection{context: "desktop-linux", setBy: configFileSetting}, "unix:///x", "Docker Desktop"}, + {engineSelection{setBy: "DOCKER_HOST"}, "unix:///Users/me/.rd/docker.sock", "Rancher Desktop"}, + {engineSelection{}, "unix:///var/run/docker.sock", ""}, + {engineSelection{context: "remote", setBy: configFileSetting}, "tcp://10.0.0.1:2376", ""}, + } + for _, tt := range tests { + if got := engineName(tt.sel, tt.host); got != tt.want { + t.Errorf("engineName(%+v, %q) = %q, want %q", tt.sel, tt.host, got, tt.want) + } + } +} diff --git a/internal/cmd/compute/build/pipeline.go b/internal/cmd/compute/build/pipeline.go index 30eb66fb..f37fa0a1 100644 --- a/internal/cmd/compute/build/pipeline.go +++ b/internal/cmd/compute/build/pipeline.go @@ -136,6 +136,7 @@ func buildStageRootFS(ctx context.Context, opts *Options, stage string, entrypoi stageOpts.BuildTarget = stage progress("searching stage %q for runtime files", stage) if _, err := buildDockerfileFinalStageQuietly(ctx, dockerfileFinalStageRequest{ + Address: stageOpts.BuildkitHost, ContextDir: stageOpts.ContextDir, Dockerfile: stageOpts.Dockerfile, Target: stageOpts.BuildTarget, @@ -143,7 +144,7 @@ func buildStageRootFS(ctx context.Context, opts *Options, stage string, entrypoi RootFSTar: rootfsTar, OCITar: ociTar, }); err != nil { - return nil, rootfsBuildError(err) + return nil, rootfsBuildError(withConnectDetails(opts, err)) } view, err := openTarFSView(rootfsTar) if err != nil { diff --git a/internal/cmd/compute/build/rootfs.go b/internal/cmd/compute/build/rootfs.go index e981d6e3..3ee012c6 100644 --- a/internal/cmd/compute/build/rootfs.go +++ b/internal/cmd/compute/build/rootfs.go @@ -2,6 +2,7 @@ package build import ( "context" + "errors" "fmt" "io" "os" @@ -31,24 +32,26 @@ type packagingArtifact struct { // fragile against upstream wording changes, but there's no structured error // type to match on instead. func rootfsBuildError(err error) error { + if errors.As(err, new(*connectError)) { + return err + } msg := err.Error() if strings.Contains(msg, "could not create EroFS archive") && strings.Contains(msg, "could not create symlink") { return fmt.Errorf("building root filesystem: EROFS packaging failed on duplicate symlink metadata") } - for _, s := range []string{ - "could not connect to buildkit", - "could not start ephemeral BuildKit container", - "creating buildkit container", - "creating container buildkit client", - "connecting to buildkit client", - } { - if strings.Contains(msg, s) { - return fmt.Errorf("building root filesystem: Docker is not running or is not accessible") - } - } return fmt.Errorf("building root filesystem: %w", err) } +// withConnectDetails appends a connection failure's underlying cause when +// --verbose is set. +func withConnectDetails(opts *Options, err error) error { + var ce *connectError + if opts.Verbose && errors.As(err, &ce) && ce.cause != nil { + return &connectError{message: ce.message + "\n\nDetails: " + ce.cause.Error(), cause: ce.cause} + } + return err +} + func buildFinalStage(ctx context.Context, opts *Options) (packagingArtifact, error) { var progress io.Writer = os.Stderr if opts.QuietBuild { @@ -73,6 +76,7 @@ func buildFinalStage(ctx context.Context, opts *Options) (packagingArtifact, err fmt.Fprintln(os.Stderr, "Building Dockerfile ...") } result, err := buildDockerfileFinalStageQuietly(ctx, dockerfileFinalStageRequest{ + Address: opts.BuildkitHost, ContextDir: opts.ContextDir, Dockerfile: opts.Dockerfile, Target: opts.BuildTarget, @@ -85,7 +89,7 @@ func buildFinalStage(ctx context.Context, opts *Options) (packagingArtifact, err task.Done(err) } if err != nil { - return packagingArtifact{}, rootfsBuildError(err) + return packagingArtifact{}, rootfsBuildError(withConnectDetails(opts, err)) } return result, nil } From 2647e446d9f608c786af7c8239c4d1ddf531b5ad Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:19:38 +0100 Subject: [PATCH 03/10] fix: find registry credentials the way docker cli does --- internal/cmd/compute/build/analysis_test.go | 4 + internal/cmd/compute/build/connect.go | 31 +-- internal/cmd/compute/build/connect_test.go | 6 +- internal/cmd/compute/build/destination.go | 6 +- internal/cmd/compute/build/inspect.go | 6 +- internal/cmd/compute/build/pipeline.go | 2 +- internal/cmd/compute/build/registry_auth.go | 202 +++++++++++++++ .../cmd/compute/build/registry_auth_test.go | 230 ++++++++++++++++++ internal/cmd/compute/build/rootfs.go | 17 +- internal/cmd/compute/build/user_error.go | 27 ++ 10 files changed, 490 insertions(+), 41 deletions(-) create mode 100644 internal/cmd/compute/build/registry_auth.go create mode 100644 internal/cmd/compute/build/registry_auth_test.go create mode 100644 internal/cmd/compute/build/user_error.go diff --git a/internal/cmd/compute/build/analysis_test.go b/internal/cmd/compute/build/analysis_test.go index 81fd43c6..0cb6623b 100644 --- a/internal/cmd/compute/build/analysis_test.go +++ b/internal/cmd/compute/build/analysis_test.go @@ -1004,6 +1004,10 @@ func TestRootfsBuildError(t *testing.T) { {name: "keeps Dockerfile build failures as-is", input: `process "/bin/sh -c go build ./..." did not complete successfully: exit code: 1`, wantIn: "go build"}, + {name: "explains a missing credential helper", + input: `failed to solve: error getting credentials - err: exec: "docker-credential-desktop": executable file not found in $PATH, out: ` + "``", + wantIn: `can't be read, so your Dockerfile's base images can't be pulled.` + "\n\n" + + `Your Docker config says sign-ins are stored by "desktop"`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/compute/build/connect.go b/internal/cmd/compute/build/connect.go index 58e04f1e..ed9bd15b 100644 --- a/internal/cmd/compute/build/connect.go +++ b/internal/cmd/compute/build/connect.go @@ -38,17 +38,6 @@ const ( minEngineMajor = 23 ) -// connectError's cause is only shown with --verbose. -type connectError struct { - message string - cause error -} - -func (e *connectError) Error() string { return e.message } -func (e *connectError) Unwrap() error { return e.cause } - -func paragraphs(p ...string) string { return strings.Join(p, "\n\n") } - // connectBuildkit returns a BuildKit client, a cleanup func (possibly nil), // and a short name for what it connected to. An explicit address // (--buildkit-host, then BUILDKIT_HOST) is used as-is; otherwise a running @@ -246,7 +235,7 @@ func formatInstallOptions(opts []installOption) string { } func noBuildkitError(goos string, cause error) error { - return &connectError{ + return &userError{ message: paragraphs( "no BuildKit is available, so your image can't be built.", whyBuildkit, @@ -262,7 +251,7 @@ func noBuildkitError(goos string, cause error) error { func engineSetupError(sel engineSelection, err error) error { if sel.context != "" && errors.As(err, new(interface{ NotFound() })) { if sel.setBy == command.EnvOverrideContext { - return &connectError{ + return &userError{ message: paragraphs( fmt.Sprintf("DOCKER_CONTEXT is set to %q, but there's no Docker context with that name.", sel.context), "Unset DOCKER_CONTEXT to use your default container engine, then run this command again.", @@ -270,7 +259,7 @@ func engineSetupError(sel engineSelection, err error) error { cause: err, } } - return &connectError{ + return &userError{ message: paragraphs( fmt.Sprintf("your Docker config selects the context %q, but there's no context with that name.", sel.context), fmt.Sprintf("Remove \"currentContext\" from %s\nto use your default container engine, then run this command again.", @@ -279,7 +268,7 @@ func engineSetupError(sel engineSelection, err error) error { cause: err, } } - return &connectError{ + return &userError{ message: paragraphs( "your container engine settings couldn't be loaded, so your image can't be built.", "Run with --verbose to see why.", @@ -316,7 +305,7 @@ func engineUnreachableError(sel engineSelection, host, goos string, err error) e if sel.setBy == mobyclient.EnvOverrideHost { fix = "Start it, or unset DOCKER_HOST to use your default container engine,\nthen run this command again." } - return &connectError{ + return &userError{ message: paragraphs( subject+" isn't running, so your image can't be built.", origin+", but nothing is listening there.\n"+fix, @@ -324,7 +313,7 @@ func engineUnreachableError(sel engineSelection, host, goos string, err error) e cause: err, } case errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM): - return &connectError{ + return &userError{ message: paragraphs( fmt.Sprintf("your user can't access %s (permission denied on %s), so your image can't be built.", subject, host), "Add yourself to the docker group, then log out and back in:\n sudo usermod -aG docker $USER", @@ -332,7 +321,7 @@ func engineUnreachableError(sel engineSelection, host, goos string, err error) e cause: err, } default: - return &connectError{ + return &userError{ message: paragraphs( fmt.Sprintf("couldn't connect to %s at %s, so your image can't be built.", subject, host), "Check that it's running and reachable, then run this command again.\nRun with --verbose for details.", @@ -380,7 +369,7 @@ func noEngineBuildkitError(sel engineSelection, host string, v mobyclient.Server } version := v.Version if major, err := strconv.Atoi(strings.Split(version, ".")[0]); err == nil && major < minEngineMajor { - return &connectError{ + return &userError{ message: paragraphs( fmt.Sprintf("%s (version %s) is too old to build images.", subject, version), whyBuildkit, @@ -389,7 +378,7 @@ func noEngineBuildkitError(sel engineSelection, host string, v mobyclient.Server cause: err, } } - return &connectError{ + return &userError{ message: paragraphs( subject+" is running, but its BuildKit didn't respond, so your image can't be built.", "Restart it, then run this command again. Run with --verbose for details.", @@ -418,7 +407,7 @@ func explicitBuildkitError(address, source string, err error) error { case strings.Contains(msg, "no such file or directory") || strings.Contains(msg, "connection refused"): reason = "nothing is listening there." } - return &connectError{ + return &userError{ message: paragraphs( fmt.Sprintf("can't reach BuildKit at %s (from %s):\n%s", address, source, reason), "Check the address, or "+unset+" to use your container engine's\nbuilt-in BuildKit.", diff --git a/internal/cmd/compute/build/connect_test.go b/internal/cmd/compute/build/connect_test.go index 7dee4ce6..59702783 100644 --- a/internal/cmd/compute/build/connect_test.go +++ b/internal/cmd/compute/build/connect_test.go @@ -17,12 +17,12 @@ import ( ) // connectErr runs connectBuildkit and returns its user-facing error. -func connectErr(t *testing.T, address string) *connectError { +func connectErr(t *testing.T, address string) *userError { t.Helper() _, _, _, err := connectBuildkit(context.Background(), address) - var ce *connectError + var ce *userError if !errors.As(err, &ce) { - t.Fatalf("expected a connectError, got %v", err) + t.Fatalf("expected a userError, got %v", err) return nil } return ce diff --git a/internal/cmd/compute/build/destination.go b/internal/cmd/compute/build/destination.go index 133d219c..d3c5a7c8 100644 --- a/internal/cmd/compute/build/destination.go +++ b/internal/cmd/compute/build/destination.go @@ -12,7 +12,6 @@ import ( "path/filepath" "strings" - "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -99,12 +98,15 @@ func pushImage(ctx context.Context, opts *Options, img v1.Image) (string, error) index := computeImageIndex(img) err = remote.WriteIndex(ref, index, remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithAuthFromKeychain(registryKeychain), remote.WithProgress(updates), ) <-done task.Done(err) if err != nil { + if rerr := registryError(ref, true, err); rerr != err { + return "", withErrorDetails(opts.Verbose, rerr) + } return "", fmt.Errorf("pushing image: %w", err) } digest, err := index.Digest() diff --git a/internal/cmd/compute/build/inspect.go b/internal/cmd/compute/build/inspect.go index 24272820..65970c24 100644 --- a/internal/cmd/compute/build/inspect.go +++ b/internal/cmd/compute/build/inspect.go @@ -7,7 +7,6 @@ import ( "slices" "strings" - "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -95,10 +94,13 @@ func runInspect(cmd *cobra.Command, imageRef string, opts inspectOptions) error ropts := []remote.Option{ remote.WithContext(cmd.Context()), - remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithAuthFromKeychain(registryKeychain), } desc, err := remote.Get(ref, ropts...) if err != nil { + if rerr := registryError(ref, false, err); rerr != err { + return rerr + } return fmt.Errorf("fetching %s: %w", imageRef, err) } diff --git a/internal/cmd/compute/build/pipeline.go b/internal/cmd/compute/build/pipeline.go index f37fa0a1..eed19047 100644 --- a/internal/cmd/compute/build/pipeline.go +++ b/internal/cmd/compute/build/pipeline.go @@ -144,7 +144,7 @@ func buildStageRootFS(ctx context.Context, opts *Options, stage string, entrypoi RootFSTar: rootfsTar, OCITar: ociTar, }); err != nil { - return nil, rootfsBuildError(withConnectDetails(opts, err)) + return nil, withErrorDetails(opts.Verbose, rootfsBuildError(err)) } view, err := openTarFSView(rootfsTar) if err != nil { diff --git a/internal/cmd/compute/build/registry_auth.go b/internal/cmd/compute/build/registry_auth.go new file mode 100644 index 00000000..021fc2b4 --- /dev/null +++ b/internal/cmd/compute/build/registry_auth.go @@ -0,0 +1,202 @@ +package build + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/types" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" +) + +// registryKeychain finds registry credentials the way `docker push` does. +// authn.DefaultKeychain skips the platform credential store (e.g. macOS +// Keychain) unless config.json names it, so it stays only as a fallback for +// the Podman auth files it also reads. +var registryKeychain = authn.NewMultiKeychain(dockerConfigKeychain{}, authn.DefaultKeychain) + +type dockerConfigKeychain struct{} + +func (dockerConfigKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) { + cf := config.LoadDefaultConfigFile(io.Discard) + for _, key := range []string{target.String(), target.RegistryStr()} { + if key == name.DefaultRegistry { + key = authn.DefaultAuthKey + } + cfg, err := cf.GetAuthConfig(key) + if err != nil { + return nil, err + } + // GetAuthConfig always fills in ServerAddress, so it can't count + // toward finding credentials. + cfg.ServerAddress = "" + if cfg != (types.AuthConfig{}) { + return authn.FromConfig(authn.AuthConfig{ + Username: cfg.Username, + Password: cfg.Password, + Auth: cfg.Auth, + IdentityToken: cfg.IdentityToken, + RegistryToken: cfg.RegistryToken, + }), nil + } + } + return authn.Anonymous, nil +} + +const signInSource = "datumctl uses the registry sign-ins saved on this machine, for example by\n" + + "Docker Desktop or docker login." + +func registryDisplayName(registry string) string { + if registry == name.DefaultRegistry { + return "Docker Hub" + } + return registry +} + +// registryError rewords a push or inspect failure that comes down to +// credentials or permissions. Other failures are returned unchanged. +func registryError(ref name.Reference, pushing bool, err error) error { + registry := registryDisplayName(ref.Context().RegistryStr()) + image := ref.String() + + auth, authErr := registryKeychain.Resolve(ref.Context()) + if authErr != nil { + outcome := image + " can't be inspected" + if pushing { + outcome = image + " can't be pushed" + } + return credentialStoreError(ref.Context().RegistryStr(), outcome, pushing, authErr) + } + signedIn := auth != authn.Anonymous + + var terr *transport.Error + if !errors.As(err, &terr) { + return err + } + denied := terr.StatusCode == http.StatusForbidden + for _, d := range terr.Errors { + if d.Code == transport.DeniedErrorCode { + denied = true + } + } + unauthorized := terr.StatusCode == http.StatusUnauthorized + + if !pushing { + // Registries answer 401 for repositories that don't exist, so a + // failed inspect can't tell "missing" from "private". + switch { + case unauthorized && !signedIn: + return &userError{ + message: paragraphs( + fmt.Sprintf("%s doesn't exist, or you need to sign in to %s to see it.", image, registry), + signInSource+fmt.Sprintf("\nCheck the image name, or sign in to %s, then run this command again.", registry), + ), + cause: err, + } + case unauthorized || denied: + return &userError{ + message: paragraphs( + fmt.Sprintf("%s doesn't exist, or your account can't see it.", image), + "Check the image name and that your account can access it.", + ), + cause: err, + } + } + return err + } + + switch { + case denied: + return &userError{ + message: paragraphs( + fmt.Sprintf("you don't have permission to push to %s.", ref.Context()), + "Check that the repository name is right and that your account can push to it.", + ), + cause: err, + } + case unauthorized && !signedIn: + return &userError{ + message: paragraphs( + fmt.Sprintf("you're not signed in to %s, so %s can't be pushed.", registry, image), + signInSource+fmt.Sprintf(" Sign in to %s, then run this command again.", registry), + ), + cause: err, + } + case unauthorized: + return &userError{ + message: paragraphs( + fmt.Sprintf("%s didn't accept your saved sign-in, so %s can't be pushed.", registry, image), + fmt.Sprintf("Your sign-in may have expired, or your account can't push to %s.\n"+ + "Sign in to %s again, then run this command again.", ref.Context(), registry), + ), + cause: err, + } + } + return err +} + +// credentialStoreError explains a credential helper failure. registry may be +// empty when the failing lookup's registry isn't known. +func credentialStoreError(registry, outcome string, hasVerbose bool, err error) error { + headline := fmt.Sprintf("your saved registry sign-ins can't be read, so %s.", outcome) + + cf := config.LoadDefaultConfigFile(io.Discard) + helper, setting := cf.CredentialsStore, `"credsStore"` + if h, ok := cf.CredentialHelpers[registry]; ok && registry != "" { + helper, setting = h, fmt.Sprintf("the %q entry in \"credHelpers\"", registry) + } + if m := credentialHelperName.FindStringSubmatch(err.Error()); m != nil { + helper = m[1] + } + if helper == "" || !isMissingCredentialHelper(err) { + // inspect has no --verbose, so it shows the cause right away. + next := "Run with --verbose for details." + if !hasVerbose { + next = "Details: " + err.Error() + } + return &userError{message: paragraphs(headline, next), cause: err} + } + + explain := fmt.Sprintf("Your Docker config says sign-ins are stored by %q, but that program isn't installed.", helper) + if helper == "desktop" { + explain += "\nThis usually happens after uninstalling Docker Desktop." + } + signIn := "sign in to your registries again" + if registry != "" { + signIn = "sign in to " + registryDisplayName(registry) + " again" + } + return &userError{ + message: paragraphs( + headline, + explain+fmt.Sprintf("\nRemove %s from %s, then %s.", setting, homePath(filepath.Join(config.Dir(), config.ConfigFileName)), signIn), + ), + cause: err, + } +} + +var credentialHelperName = regexp.MustCompile(`docker-credential-([\w.-]+)`) + +func isMissingCredentialHelper(err error) bool { + return errors.Is(err, exec.ErrNotFound) || strings.Contains(err.Error(), "executable file not found") +} + +// homePath shortens paths under the home directory to ~/... +func homePath(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + if rel, ok := strings.CutPrefix(path, home+string(filepath.Separator)); ok { + return "~/" + filepath.ToSlash(rel) + } + return path +} diff --git a/internal/cmd/compute/build/registry_auth_test.go b/internal/cmd/compute/build/registry_auth_test.go new file mode 100644 index 00000000..62f5e705 --- /dev/null +++ b/internal/cmd/compute/build/registry_auth_test.go @@ -0,0 +1,230 @@ +package build + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/docker/cli/cli/config" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/spf13/cobra" +) + +func useDockerConfig(t *testing.T, configJSON string) { + t.Helper() + dir := t.TempDir() + if configJSON != "" { + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o600); err != nil { + t.Fatal(err) + } + } + prev := config.Dir() + config.SetDir(dir) + t.Cleanup(func() { config.SetDir(prev) }) + t.Setenv("DOCKER_AUTH_CONFIG", "") +} + +// fakePlatformCredentialHelper puts the platform's default credential helper +// on an otherwise empty PATH, answering every lookup with user/secret. +func fakePlatformCredentialHelper(t *testing.T) { + t.Helper() + var helper string + switch runtime.GOOS { + case "darwin": + helper = "docker-credential-osxkeychain" + case "linux": + helper = "docker-credential-secretservice" + default: + t.Skip("no default credential store on " + runtime.GOOS) + } + dir := t.TempDir() + script := "#!/bin/sh\nread server\nprintf '{\"ServerURL\":\"%s\",\"Username\":\"user\",\"Secret\":\"secret\"}' \"$server\"\n" + if err := os.WriteFile(filepath.Join(dir, helper), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) +} + +func resolveBasic(t *testing.T, ref string) *authn.AuthConfig { + t.Helper() + r, err := name.ParseReference(ref) + if err != nil { + t.Fatal(err) + return nil + } + auth, err := registryKeychain.Resolve(r.Context()) + if err != nil { + t.Fatal(err) + return nil + } + cfg, err := auth.Authorization() + if err != nil { + t.Fatal(err) + return nil + } + return cfg +} + +func TestRegistryKeychainUsesPlatformCredentialStore(t *testing.T) { + // No credsStore in config.json: docker push still finds credentials in + // the platform store, and so must we. + useDockerConfig(t, `{}`) + fakePlatformCredentialHelper(t) + + for _, ref := range []string{"ghcr.io/acme/api:latest", "acme/api:latest"} { + cfg := resolveBasic(t, ref) + if cfg.Username != "user" || cfg.Password != "secret" { + t.Errorf("%s: got %+v, want user/secret from the credential helper", ref, cfg) + } + } +} + +func TestRegistryKeychainUsesConfigAuths(t *testing.T) { + // "dXNlcjpwYXNz" is base64("user:pass"). + useDockerConfig(t, `{"auths": {"ghcr.io": {"auth": "dXNlcjpwYXNz"}}}`) + t.Setenv("PATH", t.TempDir()) + + cfg := resolveBasic(t, "ghcr.io/acme/api:latest") + if cfg.Username != "user" || cfg.Password != "pass" { + t.Errorf("got %+v, want user/pass from config.json", cfg) + } +} + +func TestRegistryKeychainAnonymousWithoutCredentials(t *testing.T) { + useDockerConfig(t, "") + t.Setenv("PATH", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + t.Setenv("REGISTRY_AUTH_FILE", "") + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + r, err := name.ParseReference("ghcr.io/acme/api:latest") + if err != nil { + t.Fatal(err) + } + auth, err := registryKeychain.Resolve(r.Context()) + if err != nil { + t.Fatal(err) + } + if auth != authn.Anonymous { + t.Errorf("expected anonymous auth, got %#v", auth) + } +} + +// fakeRegistry answers every request after the /v2/ ping with status and, +// when set, a registry error code. +func fakeRegistry(t *testing.T, status int, code string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/" { + w.WriteHeader(http.StatusOK) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if code != "" { + _, _ = fmt.Fprintf(w, `{"errors":[{"code":%q,"message":"nope"}]}`, code) + } + })) + t.Cleanup(srv.Close) + return strings.TrimPrefix(srv.URL, "http://") +} + +func noRegistryCredentials(t *testing.T) { + t.Helper() + useDockerConfig(t, "") + t.Setenv("PATH", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + t.Setenv("REGISTRY_AUTH_FILE", "") + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) +} + +func pushErr(t *testing.T, ref string) string { + t.Helper() + _, err := pushImage(context.Background(), &Options{Ref: ref}, empty.Image) + if err == nil { + t.Fatal("expected push to fail") + return "" + } + return err.Error() +} + +func inspectErr(t *testing.T, ref string) string { + t.Helper() + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + err := runInspect(cmd, ref, inspectOptions{}) + if err == nil { + t.Fatal("expected inspect to fail") + return "" + } + return err.Error() +} + +func TestPushNotSignedIn(t *testing.T) { + noRegistryCredentials(t) + host := fakeRegistry(t, http.StatusUnauthorized, "UNAUTHORIZED") + ref := host + "/acme/api:latest" + + assertContains(t, pushErr(t, ref), + "you're not signed in to "+host+", so "+ref+" can't be pushed.", + signInSource, + "Sign in to "+host+", then run this command again.", + ) +} + +func TestPushSignInRejected(t *testing.T) { + host := fakeRegistry(t, http.StatusUnauthorized, "UNAUTHORIZED") + useDockerConfig(t, fmt.Sprintf(`{"auths": {%q: {"auth": "dXNlcjpwYXNz"}}}`, host)) + t.Setenv("PATH", t.TempDir()) + ref := host + "/acme/api:latest" + + assertContains(t, pushErr(t, ref), + host+" didn't accept your saved sign-in, so "+ref+" can't be pushed.", + "Your sign-in may have expired, or your account can't push to "+host+"/acme/api.", + ) +} + +func TestPushDenied(t *testing.T) { + noRegistryCredentials(t) + host := fakeRegistry(t, http.StatusForbidden, "DENIED") + assertContains(t, pushErr(t, host+"/acme/api:latest"), + "you don't have permission to push to "+host+"/acme/api.", + ) +} + +func TestInspectMissingOrPrivate(t *testing.T) { + noRegistryCredentials(t) + host := fakeRegistry(t, http.StatusUnauthorized, "UNAUTHORIZED") + ref := host + "/acme/api:latest" + + assertContains(t, inspectErr(t, ref), + ref+" doesn't exist, or you need to sign in to "+host+" to see it.", + "Check the image name, or sign in to "+host, + ) +} + +func TestMissingCredentialHelper(t *testing.T) { + useDockerConfig(t, `{"credsStore": "desktop"}`) + t.Setenv("PATH", t.TempDir()) + host := fakeRegistry(t, http.StatusUnauthorized, "UNAUTHORIZED") + ref := host + "/acme/api:latest" + + for name, got := range map[string]string{"push": pushErr(t, ref), "inspect": inspectErr(t, ref)} { + t.Run(name, func(t *testing.T) { + assertContains(t, got, + "your saved registry sign-ins can't be read", + `says sign-ins are stored by "desktop", but that program isn't installed.`, + "after uninstalling Docker Desktop", + `Remove "credsStore" from `, + ) + }) + } +} diff --git a/internal/cmd/compute/build/rootfs.go b/internal/cmd/compute/build/rootfs.go index 3ee012c6..d0b4f1fd 100644 --- a/internal/cmd/compute/build/rootfs.go +++ b/internal/cmd/compute/build/rootfs.go @@ -32,26 +32,19 @@ type packagingArtifact struct { // fragile against upstream wording changes, but there's no structured error // type to match on instead. func rootfsBuildError(err error) error { - if errors.As(err, new(*connectError)) { + if errors.As(err, new(*userError)) { return err } msg := err.Error() + if strings.Contains(msg, "error getting credentials") && isMissingCredentialHelper(err) { + return credentialStoreError("", "your Dockerfile's base images can't be pulled", true, err) + } if strings.Contains(msg, "could not create EroFS archive") && strings.Contains(msg, "could not create symlink") { return fmt.Errorf("building root filesystem: EROFS packaging failed on duplicate symlink metadata") } return fmt.Errorf("building root filesystem: %w", err) } -// withConnectDetails appends a connection failure's underlying cause when -// --verbose is set. -func withConnectDetails(opts *Options, err error) error { - var ce *connectError - if opts.Verbose && errors.As(err, &ce) && ce.cause != nil { - return &connectError{message: ce.message + "\n\nDetails: " + ce.cause.Error(), cause: ce.cause} - } - return err -} - func buildFinalStage(ctx context.Context, opts *Options) (packagingArtifact, error) { var progress io.Writer = os.Stderr if opts.QuietBuild { @@ -89,7 +82,7 @@ func buildFinalStage(ctx context.Context, opts *Options) (packagingArtifact, err task.Done(err) } if err != nil { - return packagingArtifact{}, rootfsBuildError(withConnectDetails(opts, err)) + return packagingArtifact{}, withErrorDetails(opts.Verbose, rootfsBuildError(err)) } return result, nil } diff --git a/internal/cmd/compute/build/user_error.go b/internal/cmd/compute/build/user_error.go new file mode 100644 index 00000000..601085a4 --- /dev/null +++ b/internal/cmd/compute/build/user_error.go @@ -0,0 +1,27 @@ +package build + +import ( + "errors" + "strings" +) + +// userError is a failure worded for the user. Its cause is only shown with +// --verbose. +type userError struct { + message string + cause error +} + +func (e *userError) Error() string { return e.message } +func (e *userError) Unwrap() error { return e.cause } + +func paragraphs(p ...string) string { return strings.Join(p, "\n\n") } + +// withErrorDetails appends a userError's underlying cause when verbose. +func withErrorDetails(verbose bool, err error) error { + var ue *userError + if verbose && errors.As(err, &ue) && ue.cause != nil { + return &userError{message: ue.message + "\n\nDetails: " + ue.cause.Error(), cause: ue.cause} + } + return err +} From 3abdd8f5a78e30a2d69d545dbdf1bba1fca9edbf Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:27:29 +0100 Subject: [PATCH 04/10] feat: add build inspect support for image archives --- internal/cmd/compute/build/destination.go | 14 ++- internal/cmd/compute/build/inspect.go | 89 +++++++++++++- internal/cmd/compute/build/inspect_test.go | 112 ++++++++++++++++++ .../cmd/compute/build/registry_auth_test.go | 6 +- 4 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 internal/cmd/compute/build/inspect_test.go diff --git a/internal/cmd/compute/build/destination.go b/internal/cmd/compute/build/destination.go index d3c5a7c8..0126b820 100644 --- a/internal/cmd/compute/build/destination.go +++ b/internal/cmd/compute/build/destination.go @@ -261,6 +261,7 @@ func tarDirectory(src, dest string) error { return closeOutErr } +// untar extracts a tar archive, gzipped or not, into dest. func untar(archivePath, dest string) error { f, err := os.Open(archivePath) if err != nil { @@ -268,7 +269,18 @@ func untar(archivePath, dest string) error { } defer f.Close() - tr := tar.NewReader(f) + br := bufio.NewReader(f) + var r io.Reader = br + if magic, _ := br.Peek(2); len(magic) == 2 && magic[0] == 0x1f && magic[1] == 0x8b { + gz, err := gzip.NewReader(br) + if err != nil { + return err + } + defer gz.Close() + r = gz + } + + tr := tar.NewReader(r) for { hdr, err := tr.Next() if err == io.EOF { diff --git a/internal/cmd/compute/build/inspect.go b/internal/cmd/compute/build/inspect.go index 65970c24..f9a2eceb 100644 --- a/internal/cmd/compute/build/inspect.go +++ b/internal/cmd/compute/build/inspect.go @@ -9,6 +9,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/types" "github.com/spf13/cobra" @@ -66,17 +67,26 @@ type inspectOptions struct { func inspectCommand() *cobra.Command { opts := inspectOptions{} cmd := &cobra.Command{ - Use: "inspect IMAGE", + Use: "inspect IMAGE|PATH", Short: "Inspect a built Compute image", Long: `Inspect a built Compute image and summarize whether it has the pieces needed to launch on Datum Compute. -This is a diagnostic command for images that were already written or pushed. It -shows the selected image variant, packaged filesystem metadata, startup +This is a diagnostic command for images that were already saved or pushed. Pass +a registry image, or an image you saved with build --output. Inspect shows the +selected image variant, packaged filesystem metadata, startup configuration, environment, and any issues that make the image look incomplete. Inspection is lightweight and never downloads the packaged filesystem layer. Use --extended to show additional OCI metadata from the image index and manifest.`, + Example: ` +# Inspect an image in a registry +datumctl compute build inspect ghcr.io/acme/api:latest + +# Inspect an image saved with build --output +datumctl compute build inspect ./compute-image.tar +datumctl compute build inspect ./compute-image +`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runInspect(cmd, args[0], opts) @@ -87,6 +97,20 @@ Inspection is lightweight and never downloads the packaged filesystem layer. Use } func runInspect(cmd *cobra.Command, imageRef string, opts inspectOptions) error { + idx, cleanup, err := openLocalImage(imageRef) + if err != nil { + return err + } + if idx != nil { + defer cleanup() + inspection := imageInspection{Ref: imageRef, Extended: opts.extended} + if err := inspectIndex(&inspection, idx); err != nil { + return err + } + printInspection(inspection) + return nil + } + ref, err := name.ParseReference(imageRef) if err != nil { return fmt.Errorf("parsing image reference: %w", err) @@ -130,6 +154,65 @@ func runInspect(cmd *cobra.Command, imageRef string, opts inspectOptions) error return nil } +const inspectTargetHint = "build inspect takes a registry image, like ghcr.io/acme/api:latest, or an image\n" + + "you saved with build --output." + +// openLocalImage opens arg as the OCI archive or layout directory that +// build --output writes. Like --output, only path-looking arguments (./x, +// /x, ~/x, x.tar) are local; anything else, such as nginx, is a registry +// reference and gets a nil index. +func openLocalImage(arg string) (v1.ImageIndex, func(), error) { + if parseOutput(arg).kind == outputRegistry { + return nil, nil, nil + } + path := expandPath(arg) + info, err := os.Stat(path) + switch { + case os.IsNotExist(err): + return nil, nil, &userError{message: paragraphs( + fmt.Sprintf("%s doesn't exist.", arg), + inspectTargetHint, + )} + case err != nil: + return nil, nil, err + } + + if info.IsDir() { + idx, err := layout.ImageIndexFromPath(path) + if err != nil { + return nil, nil, notLocalImageError(arg, err) + } + return idx, func() {}, nil + } + + dir, err := os.MkdirTemp("", "datumctl-inspect-*") + if err != nil { + return nil, nil, err + } + cleanup := func() { _ = os.RemoveAll(dir) } + if err := untar(path, dir); err != nil { + cleanup() + return nil, nil, notLocalImageError(arg, err) + } + idx, err := layout.ImageIndexFromPath(dir) + if err != nil { + cleanup() + return nil, nil, notLocalImageError(arg, err) + } + return idx, cleanup, nil +} + +func notLocalImageError(arg string, err error) error { + return &userError{ + message: paragraphs( + fmt.Sprintf("%s isn't an image saved with build --output.", arg), + inspectTargetHint, + "Details: "+err.Error(), + ), + cause: err, + } +} + func inspectIndex(out *imageInspection, idx v1.ImageIndex) error { idxDigest, err := idx.Digest() if err == nil { diff --git a/internal/cmd/compute/build/inspect_test.go b/internal/cmd/compute/build/inspect_test.go new file mode 100644 index 00000000..02104e68 --- /dev/null +++ b/internal/cmd/compute/build/inspect_test.go @@ -0,0 +1,112 @@ +package build + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +const testImageRef = "ghcr.io/acme/api:latest" + +func testComputeImage(t *testing.T) v1.Image { + t.Helper() + dir := t.TempDir() + initrd := filepath.Join(dir, "rootfs.erofs") + if err := os.WriteFile(initrd, []byte("erofs"), 0o644); err != nil { + t.Fatal(err) + } + img, err := assembleComputeImage(dir, packagingArtifact{ + Path: initrd, + Config: imageConfig{Args: []string{"/app"}}, + }) + if err != nil { + t.Fatal(err) + } + return img +} + +func TestInspectReadsBuildOutputs(t *testing.T) { + img := testComputeImage(t) + wantDigest, err := computeImageIndex(img).Digest() + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + outputs := map[string]func(string) error{ + "compute-image.tar": func(p string) error { return exportArchive(p, img) }, + "compute-image.tar.gz": func(p string) error { return exportArchive(p, img) }, + "compute-image": func(p string) error { return exportLayout(p, img) }, + } + for name, write := range outputs { + t.Run(name, func(t *testing.T) { + path := filepath.Join(dir, name) + if err := write(path); err != nil { + t.Fatal(err) + } + idx, cleanup, err := openLocalImage(path) + if err != nil { + t.Fatal(err) + } + if idx == nil { + t.Fatal("expected a local image") + return + } + defer cleanup() + + got := imageInspection{} + if err := inspectIndex(&got, idx); err != nil { + t.Fatal(err) + } + if got.IndexDigest != wantDigest.String() { + t.Errorf("digest = %s, want %s (the digest a push would report)", got.IndexDigest, wantDigest) + } + if got.SelectedPlatform != "kraftcloud/x86_64" || !got.RootFS.Found || len(got.Issues) > 0 { + t.Errorf("unexpected inspection: %+v", got) + } + }) + } +} + +func TestInspectLocalPathErrors(t *testing.T) { + dir := t.TempDir() + notImage := filepath.Join(dir, "notes") + if err := os.Mkdir(notImage, 0o755); err != nil { + t.Fatal(err) + } + + tests := []struct { + arg string + want string + }{ + {filepath.Join(dir, "missing.tar"), "missing.tar doesn't exist."}, + {"./definitely-missing-dir", "./definitely-missing-dir doesn't exist."}, + {notImage, "notes isn't an image saved with build --output."}, + } + for _, tt := range tests { + _, _, err := openLocalImage(tt.arg) + var ue *userError + if !errors.As(err, &ue) || !strings.Contains(ue.Error(), tt.want) { + t.Errorf("openLocalImage(%q) error = %v, want it to contain %q", tt.arg, err, tt.want) + } + } +} + +func TestInspectRegistryReferencesStayRemote(t *testing.T) { + // A bare name stays a registry reference even when a folder of that + // name exists, matching how --output reads it. + t.Chdir(t.TempDir()) + if err := os.Mkdir("nginx", 0o755); err != nil { + t.Fatal(err) + } + for _, arg := range []string{testImageRef, "acme/api", "nginx"} { + idx, _, err := openLocalImage(arg) + if err != nil || idx != nil { + t.Errorf("openLocalImage(%q) = %v, %v; want it treated as a registry reference", arg, idx, err) + } + } +} diff --git a/internal/cmd/compute/build/registry_auth_test.go b/internal/cmd/compute/build/registry_auth_test.go index 62f5e705..c68776b5 100644 --- a/internal/cmd/compute/build/registry_auth_test.go +++ b/internal/cmd/compute/build/registry_auth_test.go @@ -79,7 +79,7 @@ func TestRegistryKeychainUsesPlatformCredentialStore(t *testing.T) { useDockerConfig(t, `{}`) fakePlatformCredentialHelper(t) - for _, ref := range []string{"ghcr.io/acme/api:latest", "acme/api:latest"} { + for _, ref := range []string{testImageRef, "acme/api:latest"} { cfg := resolveBasic(t, ref) if cfg.Username != "user" || cfg.Password != "secret" { t.Errorf("%s: got %+v, want user/secret from the credential helper", ref, cfg) @@ -92,7 +92,7 @@ func TestRegistryKeychainUsesConfigAuths(t *testing.T) { useDockerConfig(t, `{"auths": {"ghcr.io": {"auth": "dXNlcjpwYXNz"}}}`) t.Setenv("PATH", t.TempDir()) - cfg := resolveBasic(t, "ghcr.io/acme/api:latest") + cfg := resolveBasic(t, testImageRef) if cfg.Username != "user" || cfg.Password != "pass" { t.Errorf("got %+v, want user/pass from config.json", cfg) } @@ -105,7 +105,7 @@ func TestRegistryKeychainAnonymousWithoutCredentials(t *testing.T) { t.Setenv("REGISTRY_AUTH_FILE", "") t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) - r, err := name.ParseReference("ghcr.io/acme/api:latest") + r, err := name.ParseReference(testImageRef) if err != nil { t.Fatal(err) } From ab0d0142bbd8e98cdc8d34da79119e615fbf1bae Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:15:50 +0100 Subject: [PATCH 05/10] fix: require explicit --kraftfile for unikraft cli builds --- internal/cmd/compute/build/command.go | 6 +- internal/cmd/compute/build/kraftfile.go | 46 ++++++-- internal/cmd/compute/build/kraftfile_test.go | 104 ++++++++++++++++++- internal/cmd/compute/build/pipeline.go | 17 ++- internal/cmd/compute/deploy/deploy.go | 16 +-- 5 files changed, 169 insertions(+), 20 deletions(-) diff --git a/internal/cmd/compute/build/command.go b/internal/cmd/compute/build/command.go index 7541cc8e..017f8830 100644 --- a/internal/cmd/compute/build/command.go +++ b/internal/cmd/compute/build/command.go @@ -70,9 +70,9 @@ DOCKER_HOST and DOCKER_CONTEXT the same way the docker CLI does. Use --buildkit-host (or BUILDKIT_HOST) to build with a different BuildKit, such as a buildx builder (docker-container://NAME) or a remote one (tcp://HOST:PORT). -Advanced users can provide a Kraftfile with --kraftfile (or by placing one in -the build context) to delegate the entire build to the unikraft CLI instead, -which must be installed separately. Most projects do not need one.`, +Advanced users can pass a Kraftfile with --kraftfile to delegate the entire +build to the unikraft CLI instead, which must be installed separately. A +Kraftfile is only used when passed this way. Most projects do not need one.`, Example: ` # Check that the current Dockerfile builds for Compute datumctl compute build . diff --git a/internal/cmd/compute/build/kraftfile.go b/internal/cmd/compute/build/kraftfile.go index 41d0d460..f1252331 100644 --- a/internal/cmd/compute/build/kraftfile.go +++ b/internal/cmd/compute/build/kraftfile.go @@ -21,7 +21,7 @@ var kraftfileNames = []string{ } // FindKraftfile returns the path of the first Kraftfile found in dir, or "" -// if none exist. +// if none exist. Builds only use one when it's passed with --kraftfile. func FindKraftfile(dir string) string { for _, name := range kraftfileNames { p := filepath.Join(dir, name) @@ -33,7 +33,7 @@ func FindKraftfile(dir string) string { } // runKraftBuild delegates the entire build to unikraft's own CLI -// (https://github.com/unikraft/cli) when a Kraftfile is present, instead of +// (https://github.com/unikraft/cli) for --kraftfile, instead of // reimplementing Kraftfile semantics (rootfs source/format, cmd, ...) here. // // unikraft build takes an input directory, not an explicit Kraftfile path — @@ -53,10 +53,10 @@ func runKraftBuild(ctx context.Context, opts *Options) error { unikraftPath, err := exec.LookPath("unikraft") if err != nil { - return fmt.Errorf( - "found a Kraftfile at %s, but the unikraft CLI is not installed; install it from https://github.com/unikraft/cli and re-run, or remove the Kraftfile to use the default Dockerfile-based build", - displayPath(opts.Kraftfile), - ) + return &userError{message: paragraphs( + "building from a Kraftfile needs the unikraft CLI, which isn't installed.", + "Install it from https://github.com/unikraft/cli, then run this command again.", + )} } args := []string{"build", inputDir} //nolint:goconst @@ -67,7 +67,7 @@ func runKraftBuild(ctx context.Context, opts *Options) error { args = append(args, "--output", opts.Output) } - fmt.Fprintf(os.Stderr, "Kraftfile found at %s: this build is entirely delegated to the unikraft CLI, not datumctl.\n", displayPath(opts.Kraftfile)) + fmt.Fprintf(os.Stderr, "Building from %s with the unikraft CLI.\n", filepath.Base(opts.Kraftfile)) fmt.Fprintf(os.Stderr, "Running: %s\n", formatCommand(unikraftPath, args)) cmd := exec.CommandContext(ctx, unikraftPath, args...) @@ -80,6 +80,38 @@ func runKraftBuild(ctx context.Context, opts *Options) error { return nil } +// HasDockerfile reports whether a build of dir without -f would find a +// Dockerfile, given its default name. +func HasDockerfile(dir, dockerfile string) bool { + path, err := resolveDockerfilePath(dir, dockerfile, false) + if err != nil { + return false + } + _, err = os.Stat(path) + return err == nil +} + +func missingDockerfileError(opts *Options, kraftfile string) error { + if opts.DockerfileExplicit { + return &userError{message: paragraphs( + fmt.Sprintf("there's no Dockerfile at %s.", displayPath(opts.Dockerfile)), + "Check the path you passed to -f.", + )} + } + where, here := "this folder", "here" + if cwd, err := os.Getwd(); err != nil || cwd != opts.ContextDir { + where, here = displayPath(opts.ContextDir), "there" + if !filepath.IsAbs(where) { + where = "./" + where + } + } + next := "Add a Dockerfile, or use -f to point to one." + if kraftfile != "" { + next = fmt.Sprintf("To build from the %s %s, add --kraftfile %s.", filepath.Base(kraftfile), here, displayPath(kraftfile)) + } + return &userError{message: paragraphs(fmt.Sprintf("there's no Dockerfile in %s.", where), next)} +} + // formatCommand renders path and args as a copy-pasteable shell command, // quoting only the arguments that actually need it for readability. func formatCommand(path string, args []string) string { diff --git a/internal/cmd/compute/build/kraftfile_test.go b/internal/cmd/compute/build/kraftfile_test.go index 61dc67e7..59b6cdab 100644 --- a/internal/cmd/compute/build/kraftfile_test.go +++ b/internal/cmd/compute/build/kraftfile_test.go @@ -41,7 +41,7 @@ func TestRunKraftBuildPromptsToInstallWhenMissing(t *testing.T) { if err == nil { t.Fatal("expected an error when unikraft is not installed") } - if !strings.Contains(err.Error(), "unikraft CLI is not installed") { + if !strings.Contains(err.Error(), "building from a Kraftfile needs the unikraft CLI, which isn't installed.") { t.Fatalf("expected an install prompt, got: %v", err) } } @@ -79,3 +79,105 @@ func TestFormatCommandQuotesOnlyWhenNeeded(t *testing.T) { t.Fatalf("expected %q, got %q", want, got) } } + +const testDockerfile = "Dockerfile" + +func writeFiles(t *testing.T, dir string, names ...string) { + t.Helper() + for _, name := range names { + if err := os.WriteFile(filepath.Join(dir, name), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestRunDoesNotDiscoverKraftfile(t *testing.T) { + // With unikraft off PATH, a handoff would fail with "needs the unikraft + // CLI"; reaching BuildKit instead proves the Dockerfile was built. + t.Setenv("PATH", t.TempDir()) + t.Setenv("BUILDKIT_HOST", "unix://"+filepath.Join(shortTempDir(t), "missing.sock")) + dir := t.TempDir() + writeFiles(t, dir, testDockerfile, "Kraftfile") + + _, err := Run(context.Background(), &Options{ContextDir: dir, Dockerfile: testDockerfile}) + if err == nil || !strings.Contains(err.Error(), "can't reach BuildKit") { + t.Fatalf("expected the Dockerfile build to reach BuildKit, got: %v", err) + } +} + +func TestRunMissingDockerfile(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + tests := []struct { + name string + files []string + explicit bool + want []string + }{ + { + name: "Kraftfile only", + files: []string{"Kraftfile"}, + want: []string{"there's no Dockerfile in ", "To build from the Kraftfile there, add --kraftfile "}, + }, + { + name: "empty folder", + want: []string{"there's no Dockerfile in ", "Add a Dockerfile, or use -f to point to one."}, + }, + { + name: "explicit -f", + explicit: true, + want: []string{"there's no Dockerfile at ", "Check the path you passed to -f."}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeFiles(t, dir, tt.files...) + _, err := Run(context.Background(), &Options{ContextDir: dir, Dockerfile: testDockerfile, DockerfileExplicit: tt.explicit}) + if err == nil { + t.Fatal("expected an error") + } + assertContains(t, err.Error(), tt.want...) + }) + } +} + +func TestRunMissingDockerfileInCurrentFolder(t *testing.T) { + dir := t.TempDir() + writeFiles(t, dir, "Kraftfile") + t.Chdir(dir) + + _, err := Run(context.Background(), &Options{ContextDir: ".", Dockerfile: testDockerfile}) + if err == nil { + t.Fatal("expected an error") + } + assertContains(t, err.Error(), + "there's no Dockerfile in this folder.\n\nTo build from the Kraftfile here, add --kraftfile Kraftfile.") +} + +func TestRunRejectsKraftfileWithFile(t *testing.T) { + dir := t.TempDir() + writeFiles(t, dir, testDockerfile, "Kraftfile") + _, err := Run(context.Background(), &Options{ + ContextDir: dir, + Dockerfile: testDockerfile, + DockerfileExplicit: true, + Kraftfile: filepath.Join(dir, "Kraftfile"), + }) + if err == nil || !strings.Contains(err.Error(), "--kraftfile and --file can't be used together.") { + t.Fatalf("expected a conflicting flags error, got: %v", err) + } +} + +func TestHasDockerfile(t *testing.T) { + for name, files := range map[string][]string{ + testDockerfile: {testDockerfile}, + "Dockerfile.datum": {"Dockerfile.datum"}, + "none": {"Kraftfile"}, + } { + dir := t.TempDir() + writeFiles(t, dir, files...) + if got, want := HasDockerfile(dir, testDockerfile), name != "none"; got != want { + t.Errorf("%s: HasDockerfile = %v, want %v", name, got, want) + } + } +} diff --git a/internal/cmd/compute/build/pipeline.go b/internal/cmd/compute/build/pipeline.go index eed19047..86115f9c 100644 --- a/internal/cmd/compute/build/pipeline.go +++ b/internal/cmd/compute/build/pipeline.go @@ -22,10 +22,13 @@ func Run(ctx context.Context, opts *Options) (string, error) { } opts.ContextDir = contextDir - if opts.Kraftfile == "" { - opts.Kraftfile = FindKraftfile(opts.ContextDir) - } if opts.Kraftfile != "" { + if opts.DockerfileExplicit { + return "", &userError{message: paragraphs( + "--kraftfile and --file can't be used together.", + "Use --file to build from a Dockerfile, or --kraftfile to build from a Kraftfile.", + )} + } return "", runKraftBuild(ctx, opts) } @@ -45,6 +48,14 @@ func Run(ctx context.Context, opts *Options) (string, error) { if err != nil { return "", err } + kraftfile := FindKraftfile(opts.ContextDir) + if _, err := os.Stat(opts.Dockerfile); os.IsNotExist(err) { + return "", missingDockerfileError(opts, kraftfile) + } + if kraftfile != "" && !opts.DockerfileExplicit { + fmt.Fprintf(os.Stderr, "Building from %s. To build from %s instead, add --kraftfile %s.\n\n", + filepath.Base(opts.Dockerfile), filepath.Base(kraftfile), displayPath(kraftfile)) + } printBuildConfig(opts) diff --git a/internal/cmd/compute/deploy/deploy.go b/internal/cmd/compute/deploy/deploy.go index 2eacc7dc..43f474a0 100644 --- a/internal/cmd/compute/deploy/deploy.go +++ b/internal/cmd/compute/deploy/deploy.go @@ -9,6 +9,7 @@ import ( "io" "os" "os/signal" + "path/filepath" "slices" "strings" @@ -311,18 +312,21 @@ func runDeploy(cmd *cobra.Command, args []string, opts *options) error { if len(args) > 0 && opts.image != "" { if opts.build != "" { - if kraftfile := build.FindKraftfile(opts.build); kraftfile != "" { + // --build only builds Dockerfiles, so a Kraftfile-only project + // has to build and deploy separately. + const dockerfile = "Dockerfile" + if kraftfile := build.FindKraftfile(opts.build); kraftfile != "" && !build.HasDockerfile(opts.build, dockerfile) { return fmt.Errorf( - "found %s: Kraftfile-based builds aren't supported by --build, since they delegate entirely to the "+ - "unikraft CLI. Run the build and deploy steps separately instead:\n"+ - " datumctl compute build --push --output .\n"+ - " datumctl compute deploy --image ", kraftfile) + "there's no Dockerfile in %s, and --build only builds from a Dockerfile.\n\n"+ + "To build from %s, build and deploy separately:\n"+ + " datumctl compute build --kraftfile %s --push --output \n"+ + " datumctl compute deploy --image ", opts.build, filepath.Base(kraftfile), kraftfile) } out := cmd.OutOrStdout() fmt.Fprintf(out, "Building %s and pushing to %s...\n", opts.build, opts.image) digest, err := build.Run(cmd.Context(), &build.Options{ ContextDir: opts.build, - Dockerfile: "Dockerfile", + Dockerfile: dockerfile, Output: opts.image, Push: true, // a combined build+deploy step always pushes: there's nothing to confirm Fix: true, // deploying a broken image is worse than auto-fixing and rebuilding From 8458f2beb8e8e3f1272d69c8dcd7e07c428dae9e Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:30:55 +0100 Subject: [PATCH 06/10] fix: make built images reproducible --- internal/cmd/compute/build/buildkit.go | 28 +++- internal/cmd/compute/build/command.go | 26 ++++ internal/cmd/compute/build/pipeline.go | 4 + .../cmd/compute/build/reproducible_test.go | 124 ++++++++++++++++++ internal/cmd/compute/build/rootfs.go | 2 +- 5 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/compute/build/reproducible_test.go diff --git a/internal/cmd/compute/build/buildkit.go b/internal/cmd/compute/build/buildkit.go index 014989f3..c6720bed 100644 --- a/internal/cmd/compute/build/buildkit.go +++ b/internal/cmd/compute/build/buildkit.go @@ -10,6 +10,7 @@ import ( "path" "path/filepath" "strings" + "time" "github.com/docker/cli/cli/config" erofs "github.com/erofs/go-erofs" @@ -90,7 +91,11 @@ func buildDockerfileFinalStage(ctx context.Context, req dockerfileFinalStageRequ return packagingArtifact{Path: req.RootFSTar, Config: config}, nil } -func createErofsFromTar(tarPath, output string) error { +// createErofsFromTar packs a rootfs tar into an EROFS image. The image's +// build time is fixed so identical input gives identical bytes; with epoch +// set, file times are also clamped to it (SOURCE_DATE_EPOCH semantics), so +// rebuilds that only touched timestamps match too. +func createErofsFromTar(tarPath, output string, epoch *time.Time) error { out, err := os.OpenFile(output, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return fmt.Errorf("creating EROFS image: %w", err) @@ -109,7 +114,11 @@ func createErofsFromTar(tarPath, output string) error { } defer os.RemoveAll(tempDir) - w := erofs.Create(out, erofs.WithTempDir(tempDir)) + var buildTime uint64 + if epoch != nil { + buildTime = uint64(epoch.Unix()) + } + w := erofs.Create(out, erofs.WithTempDir(tempDir), erofs.WithBuildTime(buildTime, 0)) regularFiles := make(map[string]string) tr := tar.NewReader(in) for { @@ -120,12 +129,23 @@ func createErofsFromTar(tarPath, output string) error { if err != nil { return fmt.Errorf("reading rootfs tar: %w", err) } + if epoch != nil { + hdr.ModTime = clampTime(hdr.ModTime, *epoch) + hdr.AccessTime = clampTime(hdr.AccessTime, *epoch) + } if err := addTarEntryToErofs(w, tempDir, regularFiles, tr, hdr); err != nil { return err } } } +func clampTime(t, limit time.Time) time.Time { + if t.After(limit) { + return limit + } + return t +} + func addTarEntryToErofs(w *erofs.Writer, tempDir string, regularFiles map[string]string, tr *tar.Reader, hdr *tar.Header) error { name, err := safeRootfsTarPath(hdr.Name) if err != nil { @@ -376,6 +396,10 @@ func buildSolveOpt(req buildRequest, output io.WriteCloser) (*bkclient.SolveOpt, if req.Target != "" { attrs["target"] = req.Target } + if epoch := os.Getenv(sourceDateEpochEnv); epoch != "" { + // Like docker buildx; an explicit --build-arg below still wins. + attrs["build-arg:"+sourceDateEpochEnv] = epoch + } for _, arg := range req.BuildArgs { key, value, ok := strings.Cut(arg, "=") if key == "" { diff --git a/internal/cmd/compute/build/command.go b/internal/cmd/compute/build/command.go index 017f8830..8a75ad15 100644 --- a/internal/cmd/compute/build/command.go +++ b/internal/cmd/compute/build/command.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" + "time" "github.com/spf13/cobra" ) @@ -26,6 +28,8 @@ type Options struct { Ref string TmpDir string Verbose bool + + sourceDateEpoch *time.Time } func Command() *cobra.Command { @@ -64,6 +68,10 @@ shared libraries that were not copied into the final image. The optional --fix flag applies safe exact-line fixes to the selected Dockerfile, then rebuilds from that file. +Building the same files twice gives the same image digest. If your Dockerfile's +steps write files stamped with the current time, set SOURCE_DATE_EPOCH (seconds +since 1970, for example from git log -1 --format=%ct) so those builds match too. + Builds run on BuildKit. By default, build uses the BuildKit built into your container engine (Docker Desktop, OrbStack, Colima, Docker Engine, ...), honoring DOCKER_HOST and DOCKER_CONTEXT the same way the docker CLI does. Use @@ -158,6 +166,24 @@ func printBuildConfig(opts *Options) { fmt.Fprintln(os.Stderr) } +const sourceDateEpochEnv = "SOURCE_DATE_EPOCH" + +func parseSourceDateEpoch() (*time.Time, error) { + value := os.Getenv(sourceDateEpochEnv) + if value == "" { + return nil, nil + } + secs, err := strconv.ParseInt(value, 10, 64) + if err != nil || secs < 0 { + return nil, &userError{message: paragraphs( + fmt.Sprintf("SOURCE_DATE_EPOCH is set to %q, which isn't a valid timestamp.", value), + "Set it to a time in seconds since 1970, like 1700000000, or unset it.", + )} + } + t := time.Unix(secs, 0).UTC() + return &t, nil +} + func displayPath(path string) string { abs, err := filepath.Abs(path) if err != nil { diff --git a/internal/cmd/compute/build/pipeline.go b/internal/cmd/compute/build/pipeline.go index 86115f9c..86deec4a 100644 --- a/internal/cmd/compute/build/pipeline.go +++ b/internal/cmd/compute/build/pipeline.go @@ -32,6 +32,10 @@ func Run(ctx context.Context, opts *Options) (string, error) { return "", runKraftBuild(ctx, opts) } + if opts.sourceDateEpoch, err = parseSourceDateEpoch(); err != nil { + return "", err + } + output := parseOutput(opts.Output) if err := validateOutputOptions(opts, output); err != nil { return "", err diff --git a/internal/cmd/compute/build/reproducible_test.go b/internal/cmd/compute/build/reproducible_test.go new file mode 100644 index 00000000..0fcbf4de --- /dev/null +++ b/internal/cmd/compute/build/reproducible_test.go @@ -0,0 +1,124 @@ +package build + +import ( + "archive/tar" + "bytes" + "io" + "os" + "path/filepath" + "testing" + "time" +) + +func writeRootfsTar(t *testing.T, modTime time.Time) string { + t.Helper() + path := filepath.Join(t.TempDir(), "rootfs.tar") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + tw := tar.NewWriter(f) + for _, hdr := range []*tar.Header{ + {Name: "app/", Typeflag: tar.TypeDir, Mode: 0o755, ModTime: modTime}, + {Name: "app/main", Typeflag: tar.TypeReg, Mode: 0o755, Size: 5, ModTime: modTime}, + } { + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if hdr.Size > 0 { + if _, err := io.WriteString(tw, "hello"); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return path +} + +func erofsBytes(t *testing.T, tarPath string, epoch *time.Time) []byte { + t.Helper() + out := filepath.Join(t.TempDir(), "rootfs.erofs") + if err := createErofsFromTar(tarPath, out, epoch); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestErofsIsByteIdenticalAcrossRuns(t *testing.T) { + tarPath := writeRootfsTar(t, time.Unix(1_700_000_000, 0)) + first := erofsBytes(t, tarPath, nil) + // The EROFS writer used to stamp the current second into the image. + time.Sleep(1100 * time.Millisecond) + if !bytes.Equal(first, erofsBytes(t, tarPath, nil)) { + t.Fatal("packaging the same rootfs twice gave different images") + } +} + +func TestErofsClampsFileTimesToSourceDateEpoch(t *testing.T) { + epoch := time.Unix(1_600_000_000, 0) + coldBuild := writeRootfsTar(t, time.Unix(1_700_000_000, 0)) + laterBuild := writeRootfsTar(t, time.Unix(1_700_000_500, 0)) + + if bytes.Equal(erofsBytes(t, coldBuild, nil), erofsBytes(t, laterBuild, nil)) { + t.Fatal("without SOURCE_DATE_EPOCH, file times should still be kept") + } + if !bytes.Equal(erofsBytes(t, coldBuild, &epoch), erofsBytes(t, laterBuild, &epoch)) { + t.Fatal("with SOURCE_DATE_EPOCH, builds that differ only in file times should match") + } +} + +func TestParseSourceDateEpoch(t *testing.T) { + t.Setenv(sourceDateEpochEnv, "") + if got, err := parseSourceDateEpoch(); got != nil || err != nil { + t.Fatalf("unset: got %v, %v", got, err) + } + + t.Setenv(sourceDateEpochEnv, "1700000000") + got, err := parseSourceDateEpoch() + if err != nil || got == nil || got.Unix() != 1_700_000_000 { + t.Fatalf("valid: got %v, %v", got, err) + } + + for _, bad := range []string{"yesterday", "-5", "1.5"} { + t.Setenv(sourceDateEpochEnv, bad) + if _, err := parseSourceDateEpoch(); err == nil { + t.Errorf("%q: expected an error", bad) + } else { + assertContains(t, err.Error(), "isn't a valid timestamp", "seconds since 1970") + } + } +} + +func TestSolveOptPassesSourceDateEpoch(t *testing.T) { + dir := t.TempDir() + dockerfile := filepath.Join(dir, "Dockerfile") + if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(sourceDateEpochEnv, "1700000000") + + for _, tt := range []struct { + buildArgs []string + want string + }{ + {nil, "1700000000"}, + {[]string{"SOURCE_DATE_EPOCH=1"}, "1"}, + } { + opt, err := buildSolveOpt(buildRequest{ContextDir: dir, Dockerfile: dockerfile, BuildArgs: tt.buildArgs}, nopWriteCloser{io.Discard}) + if err != nil { + t.Fatal(err) + } + if got := opt.FrontendAttrs["build-arg:SOURCE_DATE_EPOCH"]; got != tt.want { + t.Errorf("build args %v: SOURCE_DATE_EPOCH = %q, want %q", tt.buildArgs, got, tt.want) + } + } +} diff --git a/internal/cmd/compute/build/rootfs.go b/internal/cmd/compute/build/rootfs.go index d0b4f1fd..552b5f6a 100644 --- a/internal/cmd/compute/build/rootfs.go +++ b/internal/cmd/compute/build/rootfs.go @@ -92,7 +92,7 @@ func packageRootFS(opts *Options, build packagingArtifact) (packagingArtifact, e if err := os.MkdirAll(filepath.Dir(rootfsPath), 0o755); err != nil { return packagingArtifact{}, err } - if err := withProgress("Packaging root filesystem", func() error { return createErofsFromTar(build.Path, rootfsPath) }); err != nil { + if err := withProgress("Packaging root filesystem", func() error { return createErofsFromTar(build.Path, rootfsPath, opts.sourceDateEpoch) }); err != nil { return packagingArtifact{}, rootfsBuildError(err) } return packagingArtifact{Path: rootfsPath, Config: build.Config}, nil From 5b328a982b62590f81677ea51d3c3ebf1463aa67 Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:04:25 +0100 Subject: [PATCH 07/10] fix: let build --output replace a previously saved image folder --- internal/cmd/compute/build/destination.go | 59 ++++++++++++- .../cmd/compute/build/destination_test.go | 88 +++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/internal/cmd/compute/build/destination.go b/internal/cmd/compute/build/destination.go index 0126b820..12a444aa 100644 --- a/internal/cmd/compute/build/destination.go +++ b/internal/cmd/compute/build/destination.go @@ -10,6 +10,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "strings" "github.com/google/go-containerregistry/pkg/name" @@ -132,6 +133,11 @@ func parseOutput(value string) outputSpec { } func validateOutputOptions(opts *Options, spec outputSpec) error { + if spec.kind == outputLayout { + if err := checkLayoutDestination(spec.value); err != nil { + return err + } + } if !opts.Push { return nil } @@ -182,12 +188,57 @@ func exportArchive(path string, img v1.Image) error { return nil } +// layoutEntries are the only files a saved image folder contains. +var layoutEntries = []string{"blobs", "index.json", "oci-layout"} + +// checkLayoutDestination runs before building, so a folder the image can't +// be saved in is reported before the build's time is spent. It allows a +// missing or empty folder, or one holding only a previously saved image. +func checkLayoutDestination(arg string) error { + entries, err := os.ReadDir(expandPath(arg)) + if os.IsNotExist(err) || (err == nil && len(entries) == 0) { + return nil + } + if err != nil { + if info, statErr := os.Stat(expandPath(arg)); statErr == nil && !info.IsDir() { + return &userError{message: paragraphs( + fmt.Sprintf("%s is a file, so the image can't be saved there as a folder.", arg), + "Use a new or empty folder, or save to a file with --output "+strings.TrimSuffix(arg, filepath.Ext(arg))+".tar.", + )} + } + return fmt.Errorf("checking output folder: %w", err) + } + hasMarker := false + for _, e := range entries { + if !slices.Contains(layoutEntries, e.Name()) { + return folderHasFilesError(arg) + } + hasMarker = hasMarker || e.Name() == "oci-layout" + } + // Without the marker it may be the user's own blobs/ or index.json. + if !hasMarker { + return folderHasFilesError(arg) + } + return nil +} + +func folderHasFilesError(arg string) error { + return &userError{message: paragraphs( + fmt.Sprintf("%s already has files in it, so the image can't be saved there.", arg), + "Use a new or empty folder, or save to a file with --output "+strings.TrimSuffix(arg, "/")+".tar.", + )} +} + func exportLayout(path string, img v1.Image) error { + if err := checkLayoutDestination(path); err != nil { + return err + } path = expandPath(path) - if entries, err := os.ReadDir(path); err == nil && len(entries) > 0 { - return fmt.Errorf("OCI layout directory %s already exists and is not empty", path) - } else if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("checking OCI layout directory: %w", err) + // Anything left here is a previously saved image; replace it. + for _, name := range layoutEntries { + if err := os.RemoveAll(filepath.Join(path, name)); err != nil { + return fmt.Errorf("replacing the previous image in %s: %w", path, err) + } } if err := writeOCILayout(path, img); err != nil { return fmt.Errorf("writing OCI layout directory: %w", err) diff --git a/internal/cmd/compute/build/destination_test.go b/internal/cmd/compute/build/destination_test.go index 23a02268..8146416e 100644 --- a/internal/cmd/compute/build/destination_test.go +++ b/internal/cmd/compute/build/destination_test.go @@ -4,8 +4,10 @@ package build import ( "archive/tar" "bytes" + "context" "os" "path/filepath" + "strings" "testing" ) @@ -278,3 +280,89 @@ func TestAssembleImageUsesComputeIndexAndInitrdAnnotation(t *testing.T) { t.Fatalf("expected kraftcloud/x86_64 platform, got %#v", platform) } } + +func TestExportLayoutReplacesPreviousImage(t *testing.T) { + out := filepath.Join(t.TempDir(), "out") + for range 2 { + if err := exportLayout(out, testComputeImage(t)); err != nil { + t.Fatal(err) + } + } + idx, cleanup, err := openLocalImage(out) + if err != nil { + t.Fatal(err) + } + defer cleanup() + manifest, err := idx.IndexManifest() + if err != nil { + t.Fatal(err) + } + if len(manifest.Manifests) != 1 { + t.Fatalf("expected the second save to replace the first, got %d manifests", len(manifest.Manifests)) + } +} + +func TestExportLayoutRefusesFoldersWithOtherFiles(t *testing.T) { + out := filepath.Join(t.TempDir(), "src") + if err := os.MkdirAll(out, 0o755); err != nil { + t.Fatal(err) + } + keep := filepath.Join(out, "main.go") + if err := os.WriteFile(keep, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + err := exportLayout(out, testComputeImage(t)) + if err == nil { + t.Fatal("expected an error") + } + assertContains(t, err.Error(), + out+" already has files in it, so the image can't be saved there.", + "save to a file with --output "+out+".tar.", + ) + if _, err := os.Stat(keep); err != nil { + t.Fatalf("existing file was touched: %v", err) + } +} + +func TestExportLayoutRefusesUnmarkedImageLikeFolders(t *testing.T) { + // A folder with only blobs/ isn't a saved image without the oci-layout + // marker, so it must not be cleared. + out := filepath.Join(t.TempDir(), "data") + keep := filepath.Join(out, "blobs", "mine.bin") + if err := os.MkdirAll(filepath.Dir(keep), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keep, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + err := exportLayout(out, testComputeImage(t)) + if err == nil || !strings.Contains(err.Error(), "already has files in it") { + t.Fatalf("expected the folder to be refused, got: %v", err) + } + if _, err := os.Stat(keep); err != nil { + t.Fatalf("existing file was removed: %v", err) + } +} + +func TestOutputFolderIsCheckedBeforeBuilding(t *testing.T) { + t.Setenv("BUILDKIT_HOST", "unix://"+filepath.Join(shortTempDir(t), "missing.sock")) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + notes := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(notes, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + for _, tt := range []struct{ output, want string }{ + {dir, "already has files in it"}, + {notes, "is a file, so the image can't be saved there as a folder."}, + } { + _, err := Run(context.Background(), &Options{ContextDir: dir, Dockerfile: "Dockerfile", Output: tt.output}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("--output %s: expected %q before building, got: %v", tt.output, tt.want, err) + } + } +} From cfc7ed83d556054afae4242b73fe5b6b4a479466 Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:09:22 +0100 Subject: [PATCH 08/10] fix: say how to save the image after a preview build --- internal/cmd/compute/build/destination.go | 18 +++++++++++++++++- internal/cmd/compute/build/destination_test.go | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/cmd/compute/build/destination.go b/internal/cmd/compute/build/destination.go index 12a444aa..5988feb2 100644 --- a/internal/cmd/compute/build/destination.go +++ b/internal/cmd/compute/build/destination.go @@ -10,6 +10,7 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "slices" "strings" @@ -42,7 +43,9 @@ func handleOutput(ctx context.Context, opts *Options, spec outputSpec, img v1.Im if opts.Push { return "", fmt.Errorf("--push requires a registry output: use --output ghcr.io/acme/api:tag") } - fmt.Fprintln(os.Stderr, "Preview complete (image discarded)") + name := imageNameFor(opts.ContextDir) + fmt.Fprintf(os.Stderr, "Preview complete. The image wasn't saved: add --output ./%s.tar to save it,\n"+ + "or --output //%s:latest to push it.\n", name, name) return "", nil case outputRegistry: if !opts.Push { @@ -70,6 +73,19 @@ func handleOutput(ctx context.Context, opts *Options, spec outputSpec, img v1.Im } } +var invalidImageNameChars = regexp.MustCompile(`[^a-z0-9]+`) + +// imageNameFor suggests an image name from the build folder's name, made +// valid for a registry reference. +func imageNameFor(contextDir string) string { + name := invalidImageNameChars.ReplaceAllString(strings.ToLower(filepath.Base(contextDir)), "-") + name = strings.Trim(name, "-") + if name == "" { + return "app" + } + return name +} + // pushImage pushes img to opts.Ref and returns the pushed image pinned by // digest (repo@sha256:...), since a tag is mutable but callers that chain // into a deploy step need to pin what they just pushed. diff --git a/internal/cmd/compute/build/destination_test.go b/internal/cmd/compute/build/destination_test.go index 8146416e..0f87adfe 100644 --- a/internal/cmd/compute/build/destination_test.go +++ b/internal/cmd/compute/build/destination_test.go @@ -366,3 +366,17 @@ func TestOutputFolderIsCheckedBeforeBuilding(t *testing.T) { } } } + +func TestImageNameFor(t *testing.T) { + for dir, want := range map[string]string{ + "/src/hello": "hello", + "/src/My App": "my-app", + "/src/api_v2.server": "api-v2-server", + "/": "app", + "/src/___": "app", + } { + if got := imageNameFor(dir); got != want { + t.Errorf("imageNameFor(%q) = %q, want %q", dir, got, want) + } + } +} From 6a71e92f8c812d2bb5d3bef7ed3269b2d0ab6eb3 Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:06:25 +0100 Subject: [PATCH 09/10] fix: explain --push in plain language before building --- internal/cmd/compute/build/destination.go | 46 +++++++++------- .../cmd/compute/build/destination_test.go | 53 +++++++++++++++---- 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/internal/cmd/compute/build/destination.go b/internal/cmd/compute/build/destination.go index 5988feb2..3bf68f08 100644 --- a/internal/cmd/compute/build/destination.go +++ b/internal/cmd/compute/build/destination.go @@ -40,9 +40,6 @@ type outputSpec struct { func handleOutput(ctx context.Context, opts *Options, spec outputSpec, img v1.Image) (string, error) { switch spec.kind { case outputDebug: - if opts.Push { - return "", fmt.Errorf("--push requires a registry output: use --output ghcr.io/acme/api:tag") - } name := imageNameFor(opts.ContextDir) fmt.Fprintf(os.Stderr, "Preview complete. The image wasn't saved: add --output ./%s.tar to save it,\n"+ "or --output //%s:latest to push it.\n", name, name) @@ -54,19 +51,13 @@ func handleOutput(ctx context.Context, opts *Options, spec outputSpec, img v1.Im return "", err } if !ok { - return "", fmt.Errorf("push cancelled") + return "", &userError{message: "push cancelled, so nothing was pushed."} } } return pushImage(ctx, opts, img) case outputArchive: - if opts.Push { - return "", fmt.Errorf("--push is only valid with registry outputs") - } return "", exportArchive(spec.value, img) case outputLayout: - if opts.Push { - return "", fmt.Errorf("--push is only valid with registry outputs") - } return "", exportLayout(spec.value, img) default: return "", fmt.Errorf("unknown output type") @@ -154,20 +145,37 @@ func validateOutputOptions(opts *Options, spec outputSpec) error { return err } } - if !opts.Push { - return nil - } - if spec.kind != outputRegistry { - return fmt.Errorf("--push requires a registry output: use --output ghcr.io/acme/api:tag") + pushHint := fmt.Sprintf("--output //%s:latest", imageNameFor(opts.ContextDir)) + switch { + case opts.Push && spec.kind == outputDebug: + return &userError{message: paragraphs( + "--push needs a registry image to push to.", + "Add "+pushHint+".", + )} + case opts.Push && spec.kind != outputRegistry: + what := "a file" + if spec.kind == outputLayout { + what = "a folder" + } + return &userError{message: paragraphs( + fmt.Sprintf("--push only works with a registry image, and %s is %s.", spec.value, what), + fmt.Sprintf("Leave out --push to save to %s, or use %s to push.", spec.value, pushHint), + )} + case !opts.Push && spec.kind == outputRegistry && !stdinIsTerminal(): + // Checked before building: without a terminal there's no one to + // confirm the push, so the build would be wasted. + return &userError{message: paragraphs( + fmt.Sprintf("%s is a registry image, so saving it means pushing it.", spec.value), + "Add --push to push without being asked.", + )} } return nil } +var stdinIsTerminal = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } + func confirmRegistryPush(ref string) (bool, error) { - if !term.IsTerminal(int(os.Stdin.Fd())) { - return false, fmt.Errorf("output %q looks like a registry reference; rerun with --push to push without confirmation", ref) - } - fmt.Fprintf(os.Stderr, "Output %q looks like a registry reference. Push it? [y/N] ", ref) + fmt.Fprintf(os.Stderr, "Push to %s? [y/N] ", ref) answer, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil && err != io.EOF { return false, err diff --git a/internal/cmd/compute/build/destination_test.go b/internal/cmd/compute/build/destination_test.go index 0f87adfe..0eddc434 100644 --- a/internal/cmd/compute/build/destination_test.go +++ b/internal/cmd/compute/build/destination_test.go @@ -122,17 +122,52 @@ func TestParseOutput(t *testing.T) { } } -func TestValidateOutputOptionsRequiresRegistryForPush(t *testing.T) { - if err := validateOutputOptions(&Options{Push: true}, outputSpec{kind: outputRegistry, value: "ghcr.io/acme/api:dev"}); err != nil { - t.Fatalf("registry output with --push returned error: %v", err) - } +func TestValidateOutputOptionsPush(t *testing.T) { + prev := stdinIsTerminal + t.Cleanup(func() { stdinIsTerminal = prev }) + opts := func(push bool) *Options { return &Options{Push: push, ContextDir: "/src/hello"} } - if err := validateOutputOptions(&Options{Push: true}, outputSpec{kind: outputArchive, value: "image.tar"}); err == nil { - t.Fatal("expected local archive output with --push to fail") + tests := []struct { + name string + push bool + terminal bool + spec outputSpec + want []string + }{ + {name: "push to registry", push: true, spec: outputSpec{kind: outputRegistry, value: "ghcr.io/acme/api:dev"}}, + {name: "registry in a terminal asks later", terminal: true, spec: outputSpec{kind: outputRegistry, value: "ghcr.io/acme/api:dev"}}, + { + name: "push without output", push: true, spec: outputSpec{kind: outputDebug}, + want: []string{"--push needs a registry image to push to.", "Add --output //hello:latest."}, + }, + { + name: "push to a file", push: true, spec: outputSpec{kind: outputArchive, value: "./image.tar"}, + want: []string{"--push only works with a registry image, and ./image.tar is a file.", "Leave out --push to save to ./image.tar"}, + }, + { + name: "push to a folder", push: true, spec: outputSpec{kind: outputLayout, value: filepath.Join(t.TempDir(), "out")}, + want: []string{"is a folder."}, + }, + { + name: "registry without a terminal", spec: outputSpec{kind: outputRegistry, value: "ghcr.io/acme/api:dev"}, + want: []string{"ghcr.io/acme/api:dev is a registry image, so saving it means pushing it.", "Add --push to push without being asked."}, + }, } - - if err := validateOutputOptions(&Options{Push: true}, outputSpec{kind: outputDebug}); err == nil { - t.Fatal("expected debug output with --push to fail") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdinIsTerminal = func() bool { return tt.terminal } + err := validateOutputOptions(opts(tt.push), tt.spec) + if len(tt.want) == 0 { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatal("expected an error") + } + assertContains(t, err.Error(), tt.want...) + }) } } From 7cc28856eb377b93b9930b882f117d627f9d00ae Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:24:35 +0100 Subject: [PATCH 10/10] chore: fix lint errors --- internal/cmd/compute/build/command.go | 9 ++++--- internal/cmd/compute/build/connect.go | 24 ++++++++++++------- internal/cmd/compute/build/connect_test.go | 3 ++- internal/cmd/compute/build/destination.go | 4 +++- internal/cmd/compute/build/kraftfile.go | 4 +++- .../cmd/compute/build/registry_auth_test.go | 1 + 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/internal/cmd/compute/build/command.go b/internal/cmd/compute/build/command.go index 8a75ad15..09d8c374 100644 --- a/internal/cmd/compute/build/command.go +++ b/internal/cmd/compute/build/command.go @@ -151,7 +151,7 @@ func printBuildConfig(opts *Options) { row("Context", displayPath(opts.ContextDir)) dockerfileLabel := displayPath(opts.Dockerfile) - if !opts.DockerfileExplicit && filepath.Base(opts.Dockerfile) == "Dockerfile.datum" { + if !opts.DockerfileExplicit && filepath.Base(opts.Dockerfile) == datumDockerfileName { dockerfileLabel += " (override)" } row("Dockerfile", dockerfileLabel) @@ -166,7 +166,10 @@ func printBuildConfig(opts *Options) { fmt.Fprintln(os.Stderr) } -const sourceDateEpochEnv = "SOURCE_DATE_EPOCH" +const ( + sourceDateEpochEnv = "SOURCE_DATE_EPOCH" + datumDockerfileName = "Dockerfile.datum" +) func parseSourceDateEpoch() (*time.Time, error) { value := os.Getenv(sourceDateEpochEnv) @@ -207,7 +210,7 @@ func resolveDockerfilePath(contextDir, dockerfile string, explicit bool) (string } return filepath.Join(contextDir, dockerfile), nil } - datumDockerfile := filepath.Join(contextDir, "Dockerfile.datum") + datumDockerfile := filepath.Join(contextDir, datumDockerfileName) if _, err := os.Stat(datumDockerfile); err == nil { return datumDockerfile, nil } else if !os.IsNotExist(err) { diff --git a/internal/cmd/compute/build/connect.go b/internal/cmd/compute/build/connect.go index ed9bd15b..cb4dc048 100644 --- a/internal/cmd/compute/build/connect.go +++ b/internal/cmd/compute/build/connect.go @@ -28,7 +28,13 @@ import ( const ( buildkitHostFlag = "--buildkit-host" dockerDesktop = "Docker Desktop" - buildkitHostEnv = "BUILDKIT_HOST" + orbStack = "OrbStack" + colima = "Colima" + + goosDarwin = "darwin" + goosLinux = "linux" + goosWindows = "windows" + buildkitHostEnv = "BUILDKIT_HOST" whyBuildkit = "Datum Compute runs your app in a lightweight virtual machine, built from your\n" + "Dockerfile. datumctl uses BuildKit as part of the build process." @@ -103,9 +109,9 @@ func currentEngineSelection() engineSelection { func engineName(sel engineSelection, host string) string { switch { case sel.context == "orbstack" || strings.Contains(host, "/.orbstack/"): - return "OrbStack" + return orbStack case strings.HasPrefix(sel.context, "colima") || strings.Contains(host, "/.colima/"): - return "Colima" + return colima case sel.context == "rancher-desktop" || strings.Contains(host, "/.rd/"): return "Rancher Desktop" case sel.context == "desktop-linux" || strings.Contains(host, "/.docker/run/"): @@ -192,7 +198,7 @@ func dialTarget(host string) (network, addr string, ok bool) { func installOptions(goos string) string { // Standalone BuildKit only runs natively on Linux-like hosts. - return formatInstallOptions(engineInstallOptions(goos, goos != "darwin" && goos != "windows")) + return formatInstallOptions(engineInstallOptions(goos, goos != goosDarwin && goos != goosWindows)) } type installOption struct{ name, link string } @@ -201,17 +207,17 @@ func engineInstallOptions(goos string, withBuildKit bool) []installOption { type option = installOption var opts []option switch goos { - case "darwin": + case goosDarwin: opts = []option{ {dockerDesktop, "https://docs.docker.com/desktop/setup/install/mac-install/"}, - {"OrbStack", "https://orbstack.dev/download"}, - {"Colima", "https://colima.run/docs/installation/"}, + {orbStack, "https://orbstack.dev/download"}, + {colima, "https://colima.run/docs/installation/"}, } - case "linux": + case goosLinux: opts = []option{ {"Docker Engine", "https://docs.docker.com/engine/install/"}, } - case "windows": + case goosWindows: opts = []option{ {dockerDesktop, "https://docs.docker.com/desktop/setup/install/windows-install/"}, } diff --git a/internal/cmd/compute/build/connect_test.go b/internal/cmd/compute/build/connect_test.go index 59702783..374916b7 100644 --- a/internal/cmd/compute/build/connect_test.go +++ b/internal/cmd/compute/build/connect_test.go @@ -1,3 +1,4 @@ +//nolint:goconst // table-driven test cases intentionally repeat literals package build import ( @@ -177,7 +178,7 @@ func TestEngineUnreachableErrorWindowsPipeMissing(t *testing.T) { // doesn't exist: its connection error wrapping the dial's PathError. err := fmt.Errorf("failed to connect to the docker API at npipe:////./pipe/docker_engine; "+ "check if the path is correct and if the daemon is running: %w", - &os.PathError{Op: "open", Path: `\\.\pipe\docker_engine`, Err: os.ErrNotExist}) + &os.PathError{Op: "dial", Path: `\\.\pipe\docker_engine`, Err: os.ErrNotExist}) got := engineUnreachableError(engineSelection{}, "npipe:////./pipe/docker_engine", "windows", err).Error() assertContains(t, got, diff --git a/internal/cmd/compute/build/destination.go b/internal/cmd/compute/build/destination.go index 3bf68f08..9f88e6b9 100644 --- a/internal/cmd/compute/build/destination.go +++ b/internal/cmd/compute/build/destination.go @@ -64,6 +64,8 @@ func handleOutput(ctx context.Context, opts *Options, spec outputSpec, img v1.Im } } +const defaultImageName = "app" + var invalidImageNameChars = regexp.MustCompile(`[^a-z0-9]+`) // imageNameFor suggests an image name from the build folder's name, made @@ -72,7 +74,7 @@ func imageNameFor(contextDir string) string { name := invalidImageNameChars.ReplaceAllString(strings.ToLower(filepath.Base(contextDir)), "-") name = strings.Trim(name, "-") if name == "" { - return "app" + return defaultImageName } return name } diff --git a/internal/cmd/compute/build/kraftfile.go b/internal/cmd/compute/build/kraftfile.go index f1252331..5c104582 100644 --- a/internal/cmd/compute/build/kraftfile.go +++ b/internal/cmd/compute/build/kraftfile.go @@ -17,9 +17,11 @@ var kraftfileNames = []string{ "kraft.yml", "Kraftfile.yml", "Kraftfile.yaml", - "Kraftfile", + kraftfileName, } +const kraftfileName = "Kraftfile" + // FindKraftfile returns the path of the first Kraftfile found in dir, or "" // if none exist. Builds only use one when it's passed with --kraftfile. func FindKraftfile(dir string) string { diff --git a/internal/cmd/compute/build/registry_auth_test.go b/internal/cmd/compute/build/registry_auth_test.go index c68776b5..190b52f5 100644 --- a/internal/cmd/compute/build/registry_auth_test.go +++ b/internal/cmd/compute/build/registry_auth_test.go @@ -1,3 +1,4 @@ +//nolint:goconst // table-driven test cases intentionally repeat literals package build import (