From 40c4fdded4559223002e4ddb9e408aae4af47a9a Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sun, 19 Apr 2026 18:51:36 -0400 Subject: [PATCH] add ability to add images from local docker daemon (#551) signed-off-by: Adam Martin --- cmd/hauler/cli/store.go | 5 +- cmd/hauler/cli/store/add.go | 65 +++++++++++++++++++++ cmd/hauler/cli/store/add_test.go | 82 +++++++++++++++++++++++++++ cmd/hauler/cli/store/sync.go | 21 ++++++- go.mod | 6 ++ go.sum | 18 ++++++ internal/flags/add.go | 2 + pkg/apis/hauler.cattle.io/v1/image.go | 1 + pkg/store/ensure_docker_host_test.go | 70 +++++++++++++++++++++++ pkg/store/store.go | 51 +++++++++++++++++ 10 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 pkg/store/ensure_docker_host_test.go diff --git a/cmd/hauler/cli/store.go b/cmd/hauler/cli/store.go index 17fbc79..3c20d14 100644 --- a/cmd/hauler/cli/store.go +++ b/cmd/hauler/cli/store.go @@ -355,7 +355,10 @@ func addStoreAddImage(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Co hauler store add image rgcrprod.azurecr.us/rancher/rke2-runtime:v1.31.5-rke2r1 --platform linux/amd64 --key carbide-key.pub # fetch image and rewrite path - hauler store add image busybox --rewrite custom-path/busybox:latest`, + hauler store add image busybox --rewrite custom-path/busybox:latest + + # add image from local Docker daemon + hauler store add image my-local-app:latest --local`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/hauler/cli/store/add.go b/cmd/hauler/cli/store/add.go index 9f92288..ae532d4 100644 --- a/cmd/hauler/cli/store/add.go +++ b/cmd/hauler/cli/store/add.go @@ -69,6 +69,17 @@ func AddImageCmd(ctx context.Context, o *flags.AddImageOpts, s *store.Layout, re cfg := v1.Image{ Name: reference, Rewrite: o.Rewrite, + Local: o.Local, + } + + if o.Local { + if o.Key != "" || o.CertIdentity != "" || o.CertIdentityRegexp != "" { + return fmt.Errorf("--local cannot be combined with cosign verification flags (--key, --certificate-identity, --certificate-identity-regexp): signatures are not available from the Docker daemon") + } + if o.Platform != "" { + l.Warnf("--platform is ignored when --local is set: the Docker daemon stores only the host platform image") + } + return storeLocalImage(ctx, s, cfg, rso, ro, o.Rewrite) } // Check if the user provided a key. @@ -95,6 +106,60 @@ func AddImageCmd(ctx context.Context, o *flags.AddImageOpts, s *store.Layout, re return storeImage(ctx, s, cfg, o.Platform, o.ExcludeExtras, rso, ro, o.Rewrite) } +func storeLocalImage(ctx context.Context, s *store.Layout, i v1.Image, _ *flags.StoreRootOpts, ro *flags.CliRootOpts, rewrite string) error { + l := log.FromContext(ctx) + + if !ro.IgnoreErrors { + envVar := os.Getenv(consts.HaulerIgnoreErrors) + if envVar == "true" { + ro.IgnoreErrors = true + } + } + + l.Infof("adding image [%s] from local Docker daemon to the store", i.Name) + + r, err := name.ParseReference(i.Name) + if err != nil { + if ro.IgnoreErrors { + l.Warnf("unable to parse image [%s]: %v... skipping...", i.Name, err) + return nil + } + l.Errorf("unable to parse image [%s]: %v", i.Name, err) + return err + } + + if err := s.AddLocalImage(ctx, r.Name()); err != nil { + if ro.IgnoreErrors { + l.Warnf("unable to add image [%s] from Docker daemon to store: %v... skipping...", r.Name(), err) + return nil + } + l.Errorf("unable to add image [%s] from Docker daemon to store: %v", r.Name(), err) + return err + } + + if rewrite != "" { + rawRewrite := rewrite + rewrite = strings.TrimPrefix(rewrite, "/") + if !strings.Contains(rewrite, ":") { + if tag, ok := r.(name.Tag); ok { + rewrite = rewrite + ":" + tag.TagStr() + } else { + return fmt.Errorf("cannot rewrite digest reference [%s] without an explicit tag in the rewrite", r.Name()) + } + } + newRef, err := name.ParseReference(rewrite) + if err != nil { + return fmt.Errorf("unable to parse rewrite name [%s]: %w", rewrite, err) + } + if err := rewriteReference(ctx, s, r, newRef, rawRewrite); err != nil { + return err + } + } + + l.Infof("successfully added image [%s] from local Docker daemon", r.Name()) + return nil +} + func storeImage(ctx context.Context, s *store.Layout, i v1.Image, platform string, excludeExtras bool, rso *flags.StoreRootOpts, ro *flags.CliRootOpts, rewrite string) error { l := log.FromContext(ctx) diff --git a/cmd/hauler/cli/store/add_test.go b/cmd/hauler/cli/store/add_test.go index 14b2a82..baad66c 100644 --- a/cmd/hauler/cli/store/add_test.go +++ b/cmd/hauler/cli/store/add_test.go @@ -855,3 +855,85 @@ func TestStoreChart_AddImages_IncludeExtras(t *testing.T) { assertArtifactKindInStore(t, s, "test/chart-image:v2", consts.KindAnnotationSboms) }) } + +// -------------------------------------------------------------------------- +// --local flag validation tests +// -------------------------------------------------------------------------- + +func TestAddImageCmd_LocalFlagValidation(t *testing.T) { + ctx := newTestContext(t) + + tests := []struct { + name string + opts *flags.AddImageOpts + }{ + { + name: "Local with Key returns error", + opts: &flags.AddImageOpts{ + Local: true, + Key: "some.pub", + }, + }, + { + name: "Local with CertIdentity returns error", + opts: &flags.AddImageOpts{ + Local: true, + CertIdentity: "foo@bar.com", + }, + }, + { + name: "Local with CertIdentityRegexp returns error", + opts: &flags.AddImageOpts{ + Local: true, + CertIdentityRegexp: ".*", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + err := AddImageCmd(ctx, tc.opts, s, "nginx:latest", rso, ro) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "--local cannot be combined") { + t.Errorf("expected '--local cannot be combined' in error, got: %v", err) + } + }) + } +} + +// -------------------------------------------------------------------------- +// storeLocalImage unit tests +// -------------------------------------------------------------------------- + +func TestStoreLocalImage_InvalidReference(t *testing.T) { + ctx := newTestContext(t) + + t.Run("malformed reference returns error", func(t *testing.T) { + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + + err := storeLocalImage(ctx, s, v1.Image{Name: "INVALID:::ref"}, rso, ro, "") + if err == nil { + t.Fatal("expected error for malformed reference, got nil") + } + }) + + t.Run("malformed reference with IgnoreErrors returns nil", func(t *testing.T) { + s := newTestStore(t) + rso := defaultRootOpts(s.Root) + ro := defaultCliOpts() + ro.IgnoreErrors = true + + err := storeLocalImage(ctx, s, v1.Image{Name: "INVALID:::ref"}, rso, ro, "") + if err != nil { + t.Fatalf("expected nil with IgnoreErrors=true, got: %v", err) + } + }) +} diff --git a/cmd/hauler/cli/store/sync.go b/cmd/hauler/cli/store/sync.go index 1efb6cf..4828319 100644 --- a/cmd/hauler/cli/store/sync.go +++ b/cmd/hauler/cli/store/sync.go @@ -316,7 +316,7 @@ func processContent(ctx context.Context, fi *os.File, o *flags.SyncOpts, s *stor a := cfg.GetAnnotations() for _, i := range cfg.Spec.Images { - if a[consts.ImageAnnotationRegistry] != "" || o.Registry != "" { + if !i.Local && (a[consts.ImageAnnotationRegistry] != "" || o.Registry != "") { newRef, _ := reference.Parse(i.Name) newReg := o.Registry if o.Registry == "" && a[consts.ImageAnnotationRegistry] != "" { @@ -331,6 +331,25 @@ func processContent(ctx context.Context, fi *os.File, o *flags.SyncOpts, s *stor i.Name = newRef.Name() } + if i.Local { + needsPubKeyVerification := a[consts.ImageAnnotationKey] != "" || o.Key != "" || i.Key != "" + needsKeylessVerification := a[consts.ImageAnnotationCertIdentityRegexp] != "" || a[consts.ImageAnnotationCertIdentity] != "" || + o.CertIdentityRegexp != "" || o.CertIdentity != "" || + i.CertIdentityRegexp != "" || i.CertIdentity != "" + if needsPubKeyVerification || needsKeylessVerification { + return fmt.Errorf("image [%s]: --local cannot be combined with cosign verification options", i.Name) + } + + rewrite := "" + if i.Rewrite != "" { + rewrite = i.Rewrite + } + if err := storeLocalImage(ctx, s, i, rso, ro, rewrite); err != nil { + return err + } + continue + } + hasAnnotationIdentityOptions := a[consts.ImageAnnotationCertIdentityRegexp] != "" || a[consts.ImageAnnotationCertIdentity] != "" hasCliIdentityOptions := o.CertIdentityRegexp != "" || o.CertIdentity != "" hasImageIdentityOptions := i.CertIdentityRegexp != "" || i.CertIdentity != "" diff --git a/go.mod b/go.mod index 361fe5b..f8bb693 100644 --- a/go.mod +++ b/go.mod @@ -112,6 +112,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v1.0.0-rc.2 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect @@ -123,11 +124,15 @@ require ( github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.2.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.4 // indirect + github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect github.com/docker/go-metrics v0.0.1 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -224,6 +229,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index 73b7430..3043346 100644 --- a/go.sum +++ b/go.sum @@ -277,6 +277,8 @@ github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34Pl github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= @@ -318,18 +320,26 @@ github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc= github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= github.com/docker/cli v29.2.0+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.4 h1:76ItO69/AP/V4yT9V4uuuItG0B1N8hvt0T0c0NN/DzI= github.com/docker/docker-credential-helpers v0.9.4/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= 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/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arXfYcAtECDFgAgHklGI8CxgjHnXKJ4= github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= @@ -706,10 +716,16 @@ github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/5 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/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/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -724,6 +740,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mozillazg/docker-credential-acr-helper v0.4.0 h1:Uoh3Z9CcpEDnLiozDx+D7oDgRq7X+R296vAqAumnOcw= github.com/mozillazg/docker-credential-acr-helper v0.4.0/go.mod h1:2kiicb3OlPytmlNC9XGkLvVC+f0qTiJw3f/mhmeeQBg= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= diff --git a/internal/flags/add.go b/internal/flags/add.go index b927866..7dc0150 100644 --- a/internal/flags/add.go +++ b/internal/flags/add.go @@ -18,6 +18,7 @@ type AddImageOpts struct { Platform string Rewrite string ExcludeExtras bool + Local bool } func (o *AddImageOpts) AddFlags(cmd *cobra.Command) { @@ -32,6 +33,7 @@ func (o *AddImageOpts) AddFlags(cmd *cobra.Command) { f.StringVarP(&o.Platform, "platform", "p", "", "(Optional) Specify the platform of the image... i.e. linux/amd64 (defaults to all)") f.StringVar(&o.Rewrite, "rewrite", "", "(EXPERIMENTAL & Optional) Rewrite artifact path to specified string") f.BoolVar(&o.ExcludeExtras, "exclude-extras", false, "(Optional) Exclude cosign signatures, attestations, SBOMs, and OCI referrers when pulling the image") + f.BoolVar(&o.Local, "local", false, "(Optional) Add image from the local Docker daemon instead of a remote registry") } type AddFileOpts struct { diff --git a/pkg/apis/hauler.cattle.io/v1/image.go b/pkg/apis/hauler.cattle.io/v1/image.go index b5cc5b6..b257e79 100644 --- a/pkg/apis/hauler.cattle.io/v1/image.go +++ b/pkg/apis/hauler.cattle.io/v1/image.go @@ -39,4 +39,5 @@ type Image struct { Platform string `json:"platform"` Rewrite string `json:"rewrite"` ExcludeExtras bool `json:"exclude-extras"` + Local bool `json:"local"` } diff --git a/pkg/store/ensure_docker_host_test.go b/pkg/store/ensure_docker_host_test.go new file mode 100644 index 0000000..42c51b3 --- /dev/null +++ b/pkg/store/ensure_docker_host_test.go @@ -0,0 +1,70 @@ +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureDockerHost(t *testing.T) { + t.Run("DOCKER_HOST already set", func(t *testing.T) { + t.Setenv("DOCKER_HOST", "unix:///some/custom/docker.sock") + + err := ensureDockerHost() + if err != nil { + t.Errorf("ensureDockerHost() returned unexpected error: %v", err) + } + if got := os.Getenv("DOCKER_HOST"); got != "unix:///some/custom/docker.sock" { + t.Errorf("DOCKER_HOST was modified: got %q, want %q", got, "unix:///some/custom/docker.sock") + } + }) + + t.Run("no socket found returns error", func(t *testing.T) { + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + t.Skip("default docker socket exists at /var/run/docker.sock") + } + + tmp := t.TempDir() + t.Setenv("DOCKER_HOST", "") + t.Setenv("HOME", tmp) + + err := ensureDockerHost() + if err == nil { + t.Fatal("ensureDockerHost() expected error when no socket exists, got nil") + } + if !strings.Contains(err.Error(), "no Docker socket found") { + t.Errorf("error %q does not contain %q", err.Error(), "no Docker socket found") + } + }) + + t.Run("sets DOCKER_HOST for Docker Desktop socket", func(t *testing.T) { + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + t.Skip("default docker socket exists at /var/run/docker.sock") + } + + tmp := t.TempDir() + t.Setenv("DOCKER_HOST", "") + t.Setenv("HOME", tmp) + + sockDir := filepath.Join(tmp, ".docker", "run") + if err := os.MkdirAll(sockDir, 0o755); err != nil { + t.Fatalf("creating socket dir: %v", err) + } + sockPath := filepath.Join(sockDir, "docker.sock") + f, err := os.Create(sockPath) + if err != nil { + t.Fatalf("creating socket file: %v", err) + } + f.Close() + + if err := ensureDockerHost(); err != nil { + t.Fatalf("ensureDockerHost() unexpected error: %v", err) + } + + want := "unix://" + sockPath + if got := os.Getenv("DOCKER_HOST"); got != want { + t.Errorf("DOCKER_HOST = %q, want %q", got, want) + } + }) +} diff --git a/pkg/store/store.go b/pkg/store/store.go index 103b7d8..ada5a71 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -15,6 +15,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" gname "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/daemon" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/static" "github.com/opencontainers/go-digest" @@ -216,6 +217,56 @@ func (l *Layout) AddImage(ctx context.Context, ref string, platform string, excl return nil } +// AddLocalImage fetches a container image from the local Docker daemon and saves it to the store. +// No cosign signatures, attestations, SBOMs, or OCI referrers are fetched (registry-only concepts). +func (l *Layout) AddLocalImage(ctx context.Context, ref string) error { + parsedRef, err := gname.ParseReference(ref) + if err != nil { + return fmt.Errorf("parsing reference %q: %w", ref, err) + } + + if err := ensureDockerHost(); err != nil { + return fmt.Errorf("failed to locate Docker daemon socket: %w -- is the Docker daemon running?", err) + } + + img, err := daemon.Image(parsedRef, daemon.WithContext(ctx)) + if err != nil { + return fmt.Errorf("failed to fetch image from Docker daemon: %w -- is the Docker daemon running?", err) + } + + if _, err := img.Digest(); err != nil { + return fmt.Errorf("getting image digest for %q: %w", ref, err) + } + + return l.writeImage(parsedRef, img, consts.KindAnnotationImage, "") +} + +// ensureDockerHost sets DOCKER_HOST if it is not already set and the default +// socket (/var/run/docker.sock) does not exist. Docker Desktop on macOS places +// its socket at ~/.docker/run/docker.sock instead of the default path. +func ensureDockerHost() error { + if os.Getenv("DOCKER_HOST") != "" { + return nil + } + + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + return nil + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + sock := filepath.Join(home, ".docker", "run", "docker.sock") + if _, err := os.Stat(sock); err != nil { + return fmt.Errorf("no Docker socket found at /var/run/docker.sock or %s", sock) + } + if err := os.Setenv("DOCKER_HOST", "unix://"+sock); err != nil { + return fmt.Errorf("setting DOCKER_HOST: %w", err) + } + return nil +} + // writeImageBlobs writes all blobs for a single image (layers, config, manifest) to the store's // blob directory. It does not add an entry to the OCI index. func (l *Layout) writeImageBlobs(img v1.Image) error {