This commit is contained in:
Snider 2026-02-10 18:00:07 +01:00 committed by GitHub
commit cea884d760
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 295 additions and 0 deletions

71
cmd/collect_docker.go Normal file
View file

@ -0,0 +1,71 @@
package cmd
import (
"fmt"
"strings"
"github.com/Snider/Borg/pkg/docker"
"github.com/spf13/cobra"
)
// collectDockerCmd represents the collect docker command
var collectDockerCmd = NewCollectDockerCmd()
func init() {
GetCollectCmd().AddCommand(GetCollectDockerCmd())
}
func GetCollectDockerCmd() *cobra.Command {
return collectDockerCmd
}
func NewCollectDockerCmd() *cobra.Command {
collectDockerCmd := &cobra.Command{
Use: "docker [image]",
Short: "Collect a Docker image",
Long: `Collect a Docker image and save it as an OCI tarball.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
imageRef := args[0]
outputFile, err := cmd.Flags().GetString("output")
if err != nil {
return fmt.Errorf("error getting output flag: %w", err)
}
allTags, err := cmd.Flags().GetBool("all-tags")
if err != nil {
return fmt.Errorf("error getting all-tags flag: %w", err)
}
platform, err := cmd.Flags().GetString("platform")
if err != nil {
return fmt.Errorf("error getting platform flag: %w", err)
}
registry, err := cmd.Flags().GetString("registry")
if err != nil {
return fmt.Errorf("error getting registry flag: %w", err)
}
if outputFile == "" && !allTags {
// Create a default output file name from the image ref
// by replacing slashes and colons with underscores.
// e.g., letheanmovement/chain:v1.0.0 -> letheanmovement_chain_v1.0.0.tar
safeRef := strings.ReplaceAll(imageRef, "/", "_")
safeRef = strings.ReplaceAll(safeRef, ":", "_")
outputFile = safeRef + ".tar"
}
err := docker.Collect(imageRef, outputFile, allTags, platform, registry)
if err != nil {
return fmt.Errorf("error collecting docker image: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), "Docker image saved to", outputFile)
return nil
},
}
collectDockerCmd.Flags().StringP("output", "o", "", "Output file for the OCI tarball")
collectDockerCmd.Flags().Bool("all-tags", false, "Collect all available tags")
collectDockerCmd.Flags().String("platform", "", "Specific platform (e.g., linux/amd64)")
collectDockerCmd.Flags().String("registry", "", "Custom registry URL")
return collectDockerCmd
}

View file

@ -0,0 +1,78 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCollectDockerCmd_Good(t *testing.T) {
t.Run("Good", func(t *testing.T) {
t.Cleanup(func() {
// Reset the command's state after the test.
RootCmd.SetArgs([]string{})
})
// Use a small, public image for testing
imageRef := "hello-world"
// Create a temporary directory to store the output
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "hello-world.tar")
// Execute the command
output, err := executeCommand(RootCmd, "collect", "docker", imageRef, "--output", outputFile)
if err != nil {
t.Fatalf("executeCommand() returned an unexpected error: %v, output: %s", err, output)
}
// Check if the output file was created
fileInfo, err := os.Stat(outputFile)
if os.IsNotExist(err) {
t.Fatalf("collect docker command did not create the output file: %s", outputFile)
}
if err != nil {
t.Fatalf("error stating output file: %v", err)
}
})
t.Run("Platform", func(t *testing.T) {
t.Cleanup(func() {
// Reset the command's state after the test.
RootCmd.SetArgs([]string{})
})
// Use a multi-platform image for testing
imageRef := "nginx"
platform := "linux/arm64"
// Create a temporary directory to store the output
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "nginx.tar")
// Execute the command
output, err := executeCommand(RootCmd, "collect", "docker", imageRef, "--output", outputFile, "--platform", platform)
if err != nil {
t.Fatalf("executeCommand() returned an unexpected error: %v, output: %s", err, output)
}
// Check if the output file was created
fileInfo, err := os.Stat(outputFile)
if os.IsNotExist(err) {
t.Fatalf("collect docker command did not create the output file: %s", outputFile)
}
if err != nil {
t.Fatalf("error stating output file: %v", err)
}
// Check if the file is not empty
if fileInfo.Size() == 0 {
t.Errorf("the created output file is empty")
}
// Check for the success message in the output
expectedOutput := "Docker image saved to"
if !strings.Contains(output, expectedOutput) {
t.Errorf("expected output to contain %q, but got %q", expectedOutput, output)
}
})
}

1
go.mod
View file

@ -31,6 +31,7 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-containerregistry v0.20.7 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect

2
go.sum
View file

@ -51,6 +51,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I=
github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM=
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=

View file

@ -30,9 +30,17 @@ github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8=
github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
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.0.3+incompatible h1:8J+PZIcF2xLd6h5sHPsp5pvvJA+Sr2wGQxHkRl53a1E=
github.com/docker/cli v29.0.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-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@ -78,6 +86,10 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg=
github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@ -98,6 +110,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/wzshiming/ctc v1.2.3 h1:q+hW3IQNsjIlOFBTGZZZeIXTElFM4grF4spW/errh/c=
github.com/wzshiming/ctc v1.2.3/go.mod h1:2tVAtIY7SUyraSk0JxvwmONNPFL4ARavPuEsg5+KA28=
github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae h1:tpXvBXC3hpQBDCc9OojJZCQMVRAbT3TTdUMP8WguXkY=

79
pkg/docker/docker.go Normal file
View file

@ -0,0 +1,79 @@
package docker
import (
"fmt"
"os"
"strings"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/crane"
v1 "github.com/google/go-containerregistry/pkg/v1"
)
// Collect pulls a Docker image and saves it as an OCI tarball.
func Collect(imageRef, outputFile string, allTags bool, platform, registry string) error {
if outputFile == "" && !allTags {
return fmt.Errorf("output file not specified")
}
if registry != "" {
imageRef = fmt.Sprintf("%s/%s", registry, imageRef)
}
var options []crane.Option
options = append(options, crane.WithAuthFromKeychain(authn.DefaultKeychain))
if platform != "" {
p, err := v1.ParsePlatform(platform)
if err != nil {
return fmt.Errorf("parsing platform %q: %w", platform, err)
}
options = append(options, crane.WithPlatform(p))
}
if allTags {
repo := imageRef
if strings.Contains(repo, ":") {
repo = strings.Split(repo, ":")[0]
}
tags, err := crane.ListTags(repo, options...)
if err != nil {
return fmt.Errorf("listing tags for %q: %w", repo, err)
}
for _, tag := range tags {
taggedImageRef := fmt.Sprintf("%s:%s", repo, tag)
img, err := crane.Pull(taggedImageRef, options...)
if err != nil {
return fmt.Errorf("pulling image %q: %w", taggedImageRef, err)
}
// a tar file can't have a colon in the name
safeTag := strings.ReplaceAll(tag, ":", "_")
outputFile := fmt.Sprintf("%s_%s.tar", repo, safeTag)
f, err := os.Create(outputFile)
if err != nil {
return fmt.Errorf("creating file %q: %w", outputFile, err)
}
defer f.Close()
if err := crane.Export(img, f); err != nil {
return fmt.Errorf("saving image %q to %q: %w", taggedImageRef, outputFile, err)
}
}
} else {
img, err := crane.Pull(imageRef, options...)
if err != nil {
return fmt.Errorf("pulling image %q: %w", imageRef, err)
}
f, err := os.Create(outputFile)
if err != nil {
return fmt.Errorf("creating file %q: %w", outputFile, err)
}
defer f.Close()
if err := crane.Export(img, f); err != nil {
return fmt.Errorf("saving image %q to %q: %w", imageRef, outputFile, err)
}
}
return nil
}

50
pkg/docker/docker_test.go Normal file
View file

@ -0,0 +1,50 @@
package docker
import (
"os"
"path/filepath"
"testing"
)
func TestCollect(t *testing.T) {
t.Run("Good", func(t *testing.T) {
// Use a small, public image for testing
imageRef := "hello-world"
// Create a temporary directory to store the output
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "hello-world.tar")
// Call the Collect function
err := Collect(imageRef, outputFile, false, "", "")
if err != nil {
t.Fatalf("Collect() returned an unexpected error: %v", err)
}
// Check if the output file was created
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
t.Fatalf("Collect() did not create the output file: %s", outputFile)
}
})
t.Run("Platform", func(t *testing.T) {
// Use a multi-platform image for testing
imageRef := "nginx"
platform := "linux/arm64"
// Create a temporary directory to store the output
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "nginx.tar")
// Call the Collect function
err := Collect(imageRef, outputFile, false, platform, "")
if err != nil {
t.Fatalf("Collect() returned an unexpected error: %v", err)
}
// Check if the output file was created
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
t.Fatalf("Collect() did not create the output file: %s", outputFile)
}
})
}