diff --git a/cmd/release/README.md b/cmd/release/README.md index 48329799..5b01eec4 100644 --- a/cmd/release/README.md +++ b/cmd/release/README.md @@ -32,6 +32,7 @@ release tag system-agent-installer-k3s rc v1.29.2 release tag k3s ga v1.29.2 release tag system-agent-installer-k3s ga v1.29.2 release stats -r rke2 -s 2024-01-01 -e 2024-12-31 +release inspect v1.29.2+rke2r1 ``` #### Cache Permissions and Docker: diff --git a/cmd/release/cmd/inspect.go b/cmd/release/cmd/inspect.go new file mode 100644 index 00000000..03a571ce --- /dev/null +++ b/cmd/release/cmd/inspect.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/google/go-containerregistry/pkg/name" + reg "github.com/rancher/ecm-distro-tools/registry" + "github.com/rancher/ecm-distro-tools/release" + "github.com/rancher/ecm-distro-tools/release/rke2" + "github.com/rancher/ecm-distro-tools/repository" + "github.com/spf13/cobra" +) + +const ( + ossRegistry = "docker.io" +) + +func archStatus(expected bool, ossInfo, primeInfo reg.Image, platform reg.Platform) string { + if !expected { + return "-" + } + + hasArch := ossInfo.Platforms[platform] && primeInfo.Platforms[platform] + if hasArch { + return "✓" + } + return "✗" +} + +func windowsStatus(expected, exists bool) string { + if !expected { + return "-" + } + if exists { + return "✓" + } + return "✗" +} + +func formatImageRef(ref name.Reference) string { + return ref.Context().RepositoryStr() + ":" + ref.Identifier() +} + +func table(w io.Writer, results []rke2.Image) { + sort.Slice(results, func(i, j int) bool { + return formatImageRef(results[i].Reference) < formatImageRef(results[j].Reference) + }) + + missingCount := 0 + for _, result := range results { + if !result.OSSImage.Exists || !result.PrimeImage.Exists { + missingCount++ + } + } + if missingCount > 0 { + fmt.Fprintln(w, missingCount, "incomplete images") + } else { + fmt.Fprintln(w, "all images OK") + } + + tw := tabwriter.NewWriter(w, 0, 8, 2, ' ', 0) + defer tw.Flush() + + fmt.Fprintln(tw, "image\toss\tprime\tsig\tamd64\tarm64\twin") + fmt.Fprintln(tw, "-----\t---\t-----\t---\t-----\t-----\t-------") + + for _, result := range results { + ossStatus := "✗" + if result.OSSImage.Exists { + ossStatus = "✓" + } + primeStatus := "✗" + if result.PrimeImage.Exists { + primeStatus = "✓" + } + tw.Write([]byte(strings.Join([]string{ + formatImageRef(result.Reference), + ossStatus, + primeStatus, + "?", // sigstore not implemented + archStatus(result.ExpectsLinuxAmd64, result.OSSImage, result.PrimeImage, reg.Platform{OS: "linux", Architecture: "amd64"}), + archStatus(result.ExpectsLinuxArm64, result.OSSImage, result.PrimeImage, reg.Platform{OS: "linux", Architecture: "arm64"}), + windowsStatus(result.ExpectsWindows, result.OSSImage.Exists && result.PrimeImage.Exists), + "", + }, "\t") + "\n")) + } +} + +func csv(w io.Writer, results []rke2.Image) { + sort.Slice(results, func(i, j int) bool { + return formatImageRef(results[i].Reference) < formatImageRef(results[j].Reference) + }) + + fmt.Fprintln(w, "image,oss,prime,sig,amd64,arm64,win") + + for _, result := range results { + ossStatus := "N" + if result.OSSImage.Exists { + ossStatus = "Y" + } + primeStatus := "N" + if result.PrimeImage.Exists { + primeStatus = "Y" + } + + amd64Status := "" + if result.ExpectsLinuxAmd64 { + if result.OSSImage.Platforms[reg.Platform{OS: "linux", Architecture: "amd64"}] && + result.PrimeImage.Platforms[reg.Platform{OS: "linux", Architecture: "amd64"}] { + amd64Status = "Y" + } else { + amd64Status = "N" + } + } + + arm64Status := "" + if result.ExpectsLinuxArm64 { + if result.OSSImage.Platforms[reg.Platform{OS: "linux", Architecture: "arm64"}] && + result.PrimeImage.Platforms[reg.Platform{OS: "linux", Architecture: "arm64"}] { + arm64Status = "Y" + } else { + arm64Status = "N" + } + } + + winStatus := "" + if result.ExpectsWindows { + if result.OSSImage.Exists && result.PrimeImage.Exists { + winStatus = "Y" + } else { + winStatus = "N" + } + } + + values := []string{ + formatImageRef(result.Reference), + ossStatus, + primeStatus, + "?", // sigstore not implemented + amd64Status, + arm64Status, + winStatus, + } + fmt.Fprintln(w, strings.Join(values, ",")) + } +} + +var inspectCmd = &cobra.Command{ + Use: "inspect [version]", + Short: "Inspect release artifacts", + Long: `Inspect release artifacts for a given version. +Currently supports inspecting the image list for published rke2 releases.`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New("expected at least one argument: [version]") + } + + ctx := context.Background() + gh := repository.NewGithub(ctx, rootConfig.Auth.GithubToken) + filesystem, err := release.NewFS(ctx, gh, "rancher", "rke2", args[0]) + if err != nil { + return err + } + + ossClient := reg.NewClient(ossRegistry, debug) + + var primeClient *reg.Client + if rootConfig.PrimeRegistry != "" { + primeClient = reg.NewClient(rootConfig.PrimeRegistry, debug) + } + + inspector := rke2.NewReleaseInspector(filesystem, ossClient, primeClient, debug) + + results, err := inspector.InspectRelease(ctx, args[0]) + if err != nil { + return err + } + + outputFormat, _ := cmd.Flags().GetString("output") + switch outputFormat { + case "csv": + csv(os.Stdout, results) + default: + table(os.Stdout, results) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(inspectCmd) + inspectCmd.Flags().StringP("output", "o", "table", "Output format (table|csv)") +} diff --git a/cmd/release/cmd/inspect_test.go b/cmd/release/cmd/inspect_test.go new file mode 100644 index 00000000..23567758 --- /dev/null +++ b/cmd/release/cmd/inspect_test.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "bytes" + "context" + "io/fs" + "os" + "testing" + "testing/fstest" + + "github.com/google/go-containerregistry/pkg/name" + reg "github.com/rancher/ecm-distro-tools/registry" + "github.com/rancher/ecm-distro-tools/release/rke2" +) + +type mockRegistryClient struct { + images map[string]reg.Image +} + +func (m *mockRegistryClient) Image(_ context.Context, ref name.Reference) (reg.Image, error) { + key := ref.Context().RepositoryStr() + ":" + ref.Identifier() + if img, ok := m.images[key]; ok { + return img, nil + } + return reg.Image{Exists: false, Platforms: make(map[reg.Platform]bool)}, nil +} + +func newMockFS() fs.FS { + return fstest.MapFS{ + "rke2-images-all.linux-amd64.txt": &fstest.MapFile{ + Data: []byte("rancher/rke2-runtime:v1.23.4-rke2r1\nrancher/rke2-cloud-provider:v1.23.4-rke2r1"), + }, + "rke2-images-all.linux-arm64.txt": &fstest.MapFile{ + Data: []byte("rancher/rke2-runtime:v1.23.4-rke2r1"), + }, + "rke2-images.windows-amd64.txt": &fstest.MapFile{ + Data: []byte("rancher/rke2-runtime-windows:v1.23.4-rke2r1"), + }, + } +} + +func TestInspectAndCSVOutput(t *testing.T) { + ossImages := map[string]reg.Image{ + "rancher/rke2-runtime:v1.23.4-rke2r1": { + Exists: true, + Platforms: map[reg.Platform]bool{ + {OS: "linux", Architecture: "amd64"}: true, + {OS: "linux", Architecture: "arm64"}: true, + }, + }, + "rancher/rke2-cloud-provider:v1.23.4-rke2r1": { + Exists: true, + Platforms: map[reg.Platform]bool{ + {OS: "linux", Architecture: "amd64"}: true, + }, + }, + } + + primeImages := map[string]reg.Image{ + "rancher/rke2-runtime:v1.23.4-rke2r1": { + Exists: true, + Platforms: map[reg.Platform]bool{ + {OS: "linux", Architecture: "amd64"}: true, + {OS: "linux", Architecture: "arm64"}: true, + }, + }, + "rancher/rke2-cloud-provider:v1.23.4-rke2r1": { + Exists: false, + Platforms: map[reg.Platform]bool{ + {OS: "linux", Architecture: "amd64"}: true, + }, + }, + } + + inspector := rke2.NewReleaseInspector( + newMockFS(), + &mockRegistryClient{images: ossImages}, + &mockRegistryClient{images: primeImages}, + false, + ) + + results, err := inspector.InspectRelease(context.Background(), "v1.23.4+rke2r1") + if err != nil { + t.Fatalf("InspectRelease() error = %v", err) + } + + var buf bytes.Buffer + csv(&buf, results) + + expectedBytes, err := os.ReadFile("testdata/inspect_test_output.csv") + if err != nil { + t.Fatalf("failed to read test data: %v", err) + } + expected := string(expectedBytes) + if got := buf.String(); got != expected { + t.Errorf("csv() output = %q, want %q", got, expected) + } +} + +func mustParseRef(s string) name.Reference { + ref, err := name.ParseReference(s) + if err != nil { + panic(err) + } + return ref +} diff --git a/cmd/release/cmd/testdata/inspect_test_output.csv b/cmd/release/cmd/testdata/inspect_test_output.csv new file mode 100644 index 00000000..2855893c --- /dev/null +++ b/cmd/release/cmd/testdata/inspect_test_output.csv @@ -0,0 +1,4 @@ +image,oss,prime,sig,amd64,arm64,win +rancher/rke2-cloud-provider:v1.23.4-rke2r1,Y,N,?,Y,, +rancher/rke2-runtime-windows:v1.23.4-rke2r1,N,N,?,,,N +rancher/rke2-runtime:v1.23.4-rke2r1,Y,Y,?,Y,Y, diff --git a/cmd/release/config/config.go b/cmd/release/config/config.go index 47adcd99..c30832a3 100644 --- a/cmd/release/config/config.go +++ b/cmd/release/config/config.go @@ -107,13 +107,14 @@ type Auth struct { // Config type Config struct { - User *User `json:"user"` - K3s *K3s `json:"k3s" validate:"omitempty"` - Rancher *Rancher `json:"rancher" validate:"omitempty"` - RKE2 *RKE2 `json:"rke2" validate:"omitempty"` - Charts *ChartsRelease `json:"charts" validate:"omitempty"` - Auth *Auth `json:"auth"` - Dashboard *Dashboard `json:"dashboard"` + User *User `json:"user"` + K3s *K3s `json:"k3s" validate:"omitempty"` + Rancher *Rancher `json:"rancher" validate:"omitempty"` + RKE2 *RKE2 `json:"rke2" validate:"omitempty"` + Charts *ChartsRelease `json:"charts" validate:"omitempty"` + Auth *Auth `json:"auth"` + Dashboard *Dashboard `json:"dashboard"` + PrimeRegistry string `json:"prime_registry"` } // OpenOnEditor opens the given config file on the user's default text editor. @@ -226,6 +227,7 @@ func ExampleConfig() (string, error) { AWSSessionToken: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", AWSDefaultRegion: "us-east-1", }, + PrimeRegistry: "example.com", } b, err := json.MarshalIndent(conf, "", " ") if err != nil { diff --git a/go.mod b/go.mod index 9ec6add1..f5a1fbb6 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/drone/drone-go v1.7.1 github.com/go-git/go-git/v5 v5.12.1-0.20240807144107-c594bae8d75d + github.com/google/go-containerregistry v0.20.2 github.com/google/go-github/v39 v39.2.0 github.com/sirupsen/logrus v1.9.3 go.opentelemetry.io/otel v1.25.0 @@ -48,21 +49,31 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.30.5 // indirect github.com/aws/smithy-go v1.20.4 // indirect github.com/cloudflare/circl v1.3.7 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/cli v27.1.1+incompatible // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.7.0 // indirect github.com/fatih/color v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.16.5 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.8 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc3 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/skeema/knownhosts v1.3.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/vbatts/tar-split v0.11.3 // indirect go.opentelemetry.io/otel/metric v1.25.0 // indirect golang.org/x/term v0.23.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect diff --git a/go.sum b/go.sum index 2fe8458e..ae56c890 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e/go.mod h1:Xa6lInWHNQnuWoF0YPSsx+INFA9qk7/7pTjwb3PInkY= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/MetalBlueberry/go-plotly v0.4.0 h1:ld/FLZIwLmPdv09ljANonwEqSoI1uNn7myLYAVjBQ48= @@ -56,6 +57,9 @@ github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7N github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k= +github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= @@ -64,6 +68,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE= +github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= +github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= github.com/drone/drone-go v1.7.1 h1:ZX+3Rs8YHUSUQ5mkuMLmm1zr1ttiiE2YGNxF3AnyDKw= github.com/drone/drone-go v1.7.1/go.mod h1:fxCf9jAnXDZV1yDr0ckTuWd1intvcQwfJmTRpTZ1mXg= github.com/elazarl/goproxy v0.0.0-20240618083138-03be62527ccb h1:2SoxRauy2IqekRMggrQk3yNI5X6omSnk6ugVbFywwXs= @@ -125,6 +135,8 @@ github.com/google/go-cmp v0.5.5/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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo= +github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8= 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= @@ -137,6 +149,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= +github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -150,6 +164,8 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -160,6 +176,10 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.12.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +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.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= +github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= @@ -177,6 +197,7 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= @@ -186,14 +207,22 @@ github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyh github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8= github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RVck= +github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= @@ -266,6 +295,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -330,3 +360,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= diff --git a/registry/registry.go b/registry/registry.go new file mode 100644 index 00000000..833886fa --- /dev/null +++ b/registry/registry.go @@ -0,0 +1,118 @@ +package registry + +import ( + "context" + "errors" + "net/http" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" +) + +type Platform struct { + OS string + Architecture string +} + +func (p Platform) String() string { + return p.OS + "/" + p.Architecture +} + +type Image struct { + Platforms map[Platform]bool + Exists bool +} + +type Client struct { + registry string +} + +func NewClient(registry string, debug bool) *Client { + return &Client{registry} +} + +func replaceRegistry(registry string, ref name.Reference) (name.Tag, error) { + newRef, err := name.NewRepository(registry + "/" + ref.Context().RepositoryStr()) + if err != nil { + return name.Tag{}, err + } + + return name.NewTag(newRef.String() + ":" + ref.Identifier()) +} + +func (c *Client) Image(ctx context.Context, ref name.Reference) (Image, error) { + info := Image{ + Platforms: make(map[Platform]bool), + } + + tagRef, err := replaceRegistry(c.registry, ref) + if err != nil { + return info, err + } + + desc, err := remote.Get(tagRef) + if err != nil { + var transportErr *transport.Error + if errors.As(err, &transportErr) && transportErr.StatusCode == http.StatusNotFound { + return info, nil + } + return info, err + } + + info.Exists = true + + if desc.MediaType.IsIndex() { + if err := c.handleMultiArchImage(desc, &info); err != nil { + return info, err + } + } else { + if err := c.handleSingleArchImage(desc, &info); err != nil { + return info, err + } + } + + return info, nil +} + +func (c *Client) handleMultiArchImage(desc *remote.Descriptor, info *Image) error { + idx, err := desc.ImageIndex() + if err != nil { + return err + } + + manifest, err := idx.IndexManifest() + if err != nil { + return err + } + + for _, m := range manifest.Manifests { + platform := Platform{ + OS: m.Platform.OS, + Architecture: m.Platform.Architecture, + } + info.Platforms[platform] = true + } + + return nil +} + +func (c *Client) handleSingleArchImage(desc *remote.Descriptor, info *Image) error { + img, err := desc.Image() + if err != nil { + return err + } + + cfg, err := img.ConfigFile() + if err != nil { + return err + } + + platform := Platform{ + OS: cfg.OS, + Architecture: cfg.Architecture, + } + info.Platforms[platform] = true + + return nil +} diff --git a/registry/registry_test.go b/registry/registry_test.go new file mode 100644 index 00000000..4e886fb7 --- /dev/null +++ b/registry/registry_test.go @@ -0,0 +1,51 @@ +package registry + +import ( + "testing" + + "github.com/google/go-containerregistry/pkg/name" +) + +func TestReplaceRegistry(t *testing.T) { + tests := []struct { + name string + registry string + inputRef string + expected string + expectErr bool + }{ + { + name: "replace docker.io registry", + registry: "custom.registry.io", + inputRef: "docker.io/library/nginx:latest", + expected: "custom.registry.io/library/nginx:latest", + expectErr: false, + }, + { + name: "handle docker library images", + registry: "custom.registry.io", + inputRef: "hello-world", + expected: "custom.registry.io/library/hello-world:latest", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref, err := name.ParseReference(tt.inputRef) + if err != nil { + t.Fatalf("failed to parse input reference: %v", err) + } + + result, err := replaceRegistry(tt.registry, ref) + if (err != nil) != tt.expectErr { + t.Errorf("replaceRegistry() error = %v, expectErr %v", err, tt.expectErr) + return + } + + if !tt.expectErr && result.Name() != tt.expected { + t.Errorf("replaceRegistry() = %v, want %v", result.Name(), tt.expected) + } + }) + } +} diff --git a/release/fs.go b/release/fs.go new file mode 100644 index 00000000..782d46e5 --- /dev/null +++ b/release/fs.go @@ -0,0 +1,133 @@ +package release + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/google/go-github/v39/github" +) + +// FS implements fs.FS for GitHub release assets +type FS struct { + ctx context.Context + client *github.Client + owner string + repo string + tag string + release *github.RepositoryRelease + assets map[string]*github.ReleaseAsset +} + +// NewFS creates a new filesystem for accessing GitHub release assets +func NewFS(ctx context.Context, client *github.Client, owner, repo, tag string) (*FS, error) { + if tag == "" { + return nil, errors.New("invalid tag provided") + } + + release, _, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) + if err != nil { + return nil, fmt.Errorf("getting release: %w", err) + } + + fs := &FS{ + ctx: ctx, + client: client, + owner: owner, + repo: repo, + tag: tag, + release: release, + assets: make(map[string]*github.ReleaseAsset), + } + + // Index assets by name for quick lookup + for _, asset := range release.Assets { + fs.assets[asset.GetName()] = asset + } + + return fs, nil +} + +// Open implements fs.FS for a GitHub release, treating assets as a filesystem +func (r *FS) Open(name string) (fs.File, error) { + // Clean and normalize the path + name = filepath.Clean(name) + name = strings.TrimPrefix(name, "/") + if name == "." { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + + asset, ok := r.assets[name] + if !ok { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + + rc, _, err := r.client.Repositories.DownloadReleaseAsset(r.ctx, r.owner, r.repo, asset.GetID(), http.DefaultClient) + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + + return &releaseFile{ + asset: asset, + readCloser: rc, + }, nil +} + +// releaseFile implements fs.File for a GitHub release asset +type releaseFile struct { + asset *github.ReleaseAsset + readCloser io.ReadCloser +} + +func (f *releaseFile) Stat() (fs.FileInfo, error) { + return &releaseFileInfo{asset: f.asset}, nil +} + +func (f *releaseFile) Read(b []byte) (int, error) { + return f.readCloser.Read(b) +} + +func (f *releaseFile) Close() error { + return f.readCloser.Close() +} + +// releaseFileInfo implements fs.FileInfo for a GitHub release asset +type releaseFileInfo struct { + asset *github.ReleaseAsset +} + +func (r *releaseFileInfo) Name() string { return r.asset.GetName() } +func (r *releaseFileInfo) Size() int64 { return int64(r.asset.GetSize()) } +func (r *releaseFileInfo) Mode() fs.FileMode { return 0444 } // read only +func (r *releaseFileInfo) ModTime() time.Time { return r.asset.GetCreatedAt().Time } +func (r *releaseFileInfo) IsDir() bool { return false } +func (r *releaseFileInfo) Sys() interface{} { return r.asset } + +func (r *FS) ReadDir(name string) ([]fs.DirEntry, error) { + name = filepath.Clean(name) + name = strings.TrimPrefix(name, "/") + if name != "." { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist} + } + + entries := make([]fs.DirEntry, 0, len(r.assets)) + for _, asset := range r.assets { + entries = append(entries, &releaseFileInfo{asset: asset}) + } + return entries, nil +} + +// releaseFileInfo implements both fs.FileInfo and fs.DirEntry +func (r *releaseFileInfo) Type() fs.FileMode { + return r.Mode() +} + +func (r *releaseFileInfo) Info() (fs.FileInfo, error) { + return r, nil +} diff --git a/release/rke2/images.go b/release/rke2/images.go new file mode 100644 index 00000000..373f05c7 --- /dev/null +++ b/release/rke2/images.go @@ -0,0 +1,202 @@ +package rke2 + +import ( + "context" + "errors" + "io" + "io/fs" + "strings" + "sync" + + "github.com/google/go-containerregistry/pkg/name" + reg "github.com/rancher/ecm-distro-tools/registry" + "golang.org/x/sync/errgroup" +) + +// RegistryClient defines the interface for interacting with container registries +type RegistryClient interface { + Image(ctx context.Context, ref name.Reference) (reg.Image, error) +} + +type Architecture string + +const ( + LinuxAmd64 Architecture = "linux/amd64" + LinuxArm64 Architecture = "linux/arm64" + WindowsAmd64 Architecture = "windows/amd64" + + ListLinuxAmd64 = "rke2-images-all.linux-amd64.txt" + ListLinuxArm64 = "rke2-images-all.linux-arm64.txt" + ListWindowsAmd64 = "rke2-images.windows-amd64.txt" +) + +// ReleaseImage is an image listed in the images file for one or more platforms of a given RKE2 release +type ReleaseImage struct { + Reference name.Reference + ExpectsLinuxAmd64 bool + ExpectsLinuxArm64 bool + ExpectsWindows bool +} + +// Image contains the manifest info of an image in the oss and prime registries +type Image struct { + ReleaseImage + OSSImage reg.Image + PrimeImage reg.Image +} + +type ReleaseInspector struct { + assets fs.FS + oss RegistryClient + prime RegistryClient + debug bool +} + +func NewReleaseInspector(fs fs.FS, oss, prime RegistryClient, debug bool) *ReleaseInspector { + return &ReleaseInspector{ + assets: fs, + oss: oss, + prime: prime, + debug: debug, + } +} + +func (r *ReleaseInspector) InspectRelease(ctx context.Context, version string) ([]Image, error) { + if !strings.Contains(version, "+rke2") { + return nil, errors.New("only RKE2 releases are currently supported") + } + + requiredImages, err := r.imageMap() + if err != nil { + return nil, err + } + + return r.checkImages(ctx, requiredImages) +} + +// imageMap reads per-platform image list files and coalesces them +// into one map to collect images for all platforms. +func (r *ReleaseInspector) imageMap() (map[string]ReleaseImage, error) { + // download image lists for release + var ( + amd64Images []string + arm64Images []string + winImages []string + ) + + g := new(errgroup.Group) + + g.Go(func() (err error) { + amd64Images, err = r.readImageList(ListLinuxAmd64) + return err + }) + g.Go(func() (err error) { + arm64Images, err = r.readImageList(ListLinuxArm64) + return err + }) + g.Go(func() (err error) { + winImages, err = r.readImageList(ListWindowsAmd64) + return + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + // merge all images into a map + imageMap := make(map[string]ReleaseImage) + for _, imagePair := range [][2]interface{}{ + {amd64Images, LinuxAmd64}, + {arm64Images, LinuxArm64}, + {winImages, WindowsAmd64}, + } { + images, arch := imagePair[0].([]string), imagePair[1].(Architecture) + for _, image := range images { + if image == "" { + continue + } + + ref, err := name.ParseReference(image) + if err != nil { + continue + } + + key := ref.Context().RepositoryStr() + ":" + ref.Identifier() + info := imageMap[key] + info.Reference = ref + + switch arch { + case LinuxAmd64: + info.ExpectsLinuxAmd64 = true + case LinuxArm64: + info.ExpectsLinuxArm64 = true + case WindowsAmd64: + info.ExpectsWindows = true + } + + imageMap[key] = info + } + } + + return imageMap, nil +} + +// readImageList reads an image list file and returns its contents +func (r *ReleaseInspector) readImageList(filename string) ([]string, error) { + file, err := r.assets.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + return strings.Split(strings.TrimSpace(string(content)), "\n"), nil +} + +// checkImages checks if the required images exist in the OSS and Prime registries +func (r *ReleaseInspector) checkImages(ctx context.Context, requiredImages map[string]ReleaseImage) ([]Image, error) { + resultChan := make(chan Image, len(requiredImages)) + var wg sync.WaitGroup + + for _, required := range requiredImages { + wg.Add(1) + go func(img ReleaseImage) { + defer wg.Done() + + ossImage, err := r.oss.Image(ctx, img.Reference) + if err != nil { + ossImage = reg.Image{ + Exists: false, + Platforms: make(map[reg.Platform]bool), + } + } + + var primeImage reg.Image + if r.prime != nil { + primeImage, _ = r.prime.Image(ctx, img.Reference) + } + + resultChan <- Image{ + ReleaseImage: img, + OSSImage: ossImage, + PrimeImage: primeImage, + } + }(required) + } + + go func() { + wg.Wait() + close(resultChan) + }() + + var results []Image + for img := range resultChan { + results = append(results, img) + } + + return results, nil +} diff --git a/release/rke2/images_test.go b/release/rke2/images_test.go new file mode 100644 index 00000000..969f22bc --- /dev/null +++ b/release/rke2/images_test.go @@ -0,0 +1,111 @@ +package rke2 + +import ( + "io/fs" + "strings" + "testing" + "testing/fstest" +) + +func newMockFS() fs.FS { + return fstest.MapFS{ + ListLinuxAmd64: &fstest.MapFile{ + Data: []byte("rancher/rke2-runtime:v1.23.4-rke2r1\nrancher/rke2-cloud-provider:v1.23.4-rke2r1"), + }, + ListLinuxArm64: &fstest.MapFile{ + Data: []byte("rancher/rke2-runtime:v1.23.4-rke2r1"), + }, + ListWindowsAmd64: &fstest.MapFile{ + Data: []byte("rancher/rke2-runtime-windows:v1.23.4-rke2r1"), + }, + } +} + +func TestImageMap(t *testing.T) { + inspector := NewReleaseInspector(newMockFS(), nil, nil, false) + + imageMap, err := inspector.imageMap() + if err != nil { + t.Fatalf("imageMap() error = %v", err) + } + + expectedImages := map[string]struct { + amd64 bool + arm64 bool + win bool + }{ + "rancher/rke2-runtime:v1.23.4-rke2r1": { + amd64: true, + arm64: true, + win: false, + }, + "rancher/rke2-cloud-provider:v1.23.4-rke2r1": { + amd64: true, + arm64: false, + win: false, + }, + "rancher/rke2-runtime-windows:v1.23.4-rke2r1": { + amd64: false, + arm64: false, + win: true, + }, + } + + for imageName, expected := range expectedImages { + image, ok := imageMap[imageName] + if !ok { + t.Errorf("imageMap() missing expected image %s", imageName) + continue + } + + if image.ExpectsLinuxAmd64 != expected.amd64 { + t.Errorf("image %s: got amd64 = %v, want %v", imageName, image.ExpectsLinuxAmd64, expected.amd64) + } + if image.ExpectsLinuxArm64 != expected.arm64 { + t.Errorf("image %s: got arm64 = %v, want %v", imageName, image.ExpectsLinuxArm64, expected.arm64) + } + if image.ExpectsWindows != expected.win { + t.Errorf("image %s: got windows = %v, want %v", imageName, image.ExpectsWindows, expected.win) + } + } +} + +func TestReadImageList(t *testing.T) { + tests := []struct { + name string + filename string + want []string + wantErr bool + }{ + { + name: "read rke2-images-all.linux-amd64.txt", + filename: "rke2-images-all.linux-amd64.txt", + want: []string{"rancher/rke2-runtime:v1.23.4-rke2r1", "rancher/rke2-cloud-provider:v1.23.4-rke2r1"}, + }, + { + name: "read nonexistent file", + filename: "fake.txt", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inspector := NewReleaseInspector(newMockFS(), nil, nil, false) + + got, err := inspector.readImageList(tt.filename) + if (err != nil) != tt.wantErr { + t.Errorf("readImageList() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err != nil { + return + } + + if strings.Join(got, ",") != strings.Join(tt.want, ",") { + t.Errorf("readImageList() = %v, want %v", got, tt.want) + } + }) + } +}