diff --git a/cmd/product.go b/cmd/product.go index 72ad519..8753f20 100644 --- a/cmd/product.go +++ b/cmd/product.go @@ -10,8 +10,8 @@ import ( ) const productf = `usage: - %[1]s product verify rancher-prime:v2.12.2 - %[1]s product artifacts rancher-prime:v2.12.2 + %[1]s product verify --registry rancher-prime:v2.12.2 + %[1]s product copy --registry rancher-prime:v2.12.2 ` func productCmd(args []string) error { @@ -33,7 +33,21 @@ func productCmd(args []string) error { return fmt.Errorf("invalid name version %q: format expected :", arg) } - return product.Verify(registry, nameVer[0], nameVer[1], true, true) + switch args[0] { + case "verify": + return product.Verify(registry, nameVer[0], nameVer[1], true, true) + case "copy": + if f.NArg() != 2 { + showProductUsage() + } + + targetRegistry := f.Arg(1) + return product.Copy(registry, nameVer[0], nameVer[1], targetRegistry) + default: + showProductUsage() + } + + return nil } func showProductUsage() { diff --git a/internal/imagelist/copier.go b/internal/imagelist/copier.go new file mode 100644 index 0000000..8aebebf --- /dev/null +++ b/internal/imagelist/copier.go @@ -0,0 +1,148 @@ +package imagelist + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" +) + +var externalImages = map[string]string{ + "sig-storage/snapshot-controller": "registry.k8s.io/sig-storage/snapshot-controller", + "sig-storage/snapshot-validation-webhook": "registry.k8s.io/sig-storage/snapshot-validation-webhook", + "rancher/mirrored-sig-storage-csi-node-driver-registrar": "registry.k8s.io/sig-storage/csi-node-driver-registrar", + "rancher/mirrored-sig-storage-csi-attacher": "registry.k8s.io/sig-storage/csi-attacher", + "rancher/mirrored-longhornio-csi-attacher": "registry.k8s.io/sig-storage/csi-attacher", + "rancher/mirrored-sig-storage-csi-provisioner": "registry.k8s.io/sig-storage/csi-provisioner", + "rancher/mirrored-sig-storage-csi-resizer": "registry.k8s.io/sig-storage/csi-resizer", + "rancher/mirrored-sig-storage-csi-snapshotter": "registry.k8s.io/sig-storage/csi-snapshotter", + "rancher/mirrored-sig-storage-livenessprobe": "registry.k8s.io/sig-storage/rancher/mirrored-sig-storage-livenessprobe", + "rancher/mirrored-sig-storage-snapshot-controller": "registry.k8s.io/sig-storage/snapshot-controller", + "rancher/appco-redis": "dp.apps.rancher.io/containers/redis", + "rancher/mirrored-cilium-cilium": "quay.io/cilium/cilium", + "rancher/mirrored-cilium-cilium-envoy": "quay.io/cilium/cilium-envoy", + "rancher/mirrored-cilium-clustermesh-apiserver": "quay.io/cilium/clustermesh-apiserver", + "rancher/mirrored-cilium-hubble-relay": "quay.io/cilium/hubble-relay", + "rancher/mirrored-cilium-operator-aws": "quay.io/cilium/operator-aws", + "rancher/mirrored-cilium-operator-azure": "quay.io/cilium/operator-azure", + "rancher/mirrored-cilium-operator-generic": "quay.io/cilium/operator-generic", + "rancher/mirrored-kube-logging-config-reloader": "ghcr.io/kube-logging/config-reloader", + "rancher/mirrored-kube-logging-logging-operator": "ghcr.io/kube-logging/logging-operator", + "rancher/mirrored-kube-state-metrics-kube-state-metrics": "registry.k8s.io/kube-state-metrics/kube-state-metrics", + "rancher/mirrored-elemental-operator": "registry.suse.com/rancher/elemental-operator", + "rancher/mirrored-elemental-seedimage-builder": "registry.suse.com/rancher/seedimage-builder", + "rancher/mirrored-cluster-api-controller": "registry.k8s.io/cluster-api/cluster-api-controller", +} + +type imageCopier struct { + m sync.Mutex + mirroredOnly bool +} + +func (i *imageCopier) Copy(srcImg, dstRegistry string) Entry { + entry := Entry{ + Image: srcImg, + } + + if i.mirroredOnly && !strings.Contains(srcImg, "mirrored") { + entry.Error = errors.New("skipping non-mirrored image: " + srcImg) + return entry + } + + ref, err := name.ParseReference(srcImg, name.WeakValidation) + if err != nil { + entry.Error = err + return entry + } + + reg, err := name.NewRegistry(dstRegistry) + if err != nil { + entry.Error = err + return entry + } + + repo := reg.Repo(ref.Context().RepositoryStr()) + dst := repo.Tag(ref.Identifier()).String() + + ctx := context.TODO() + + // Reset stdout/stderr to avoid verbose output from cosign. + i.m.Lock() + stdout := os.Stdout + stderr := os.Stderr + os.Stdout = nil + os.Stderr = nil + + // We shouldn't copy signatures if the image isn't there, so copy images + // but don't override them if they are already present. + err = copySignature(ctx, srcImg, dst, true) + if err != nil { + entry.Error = err + } + entry.Signed = (err == nil) + + os.Stdout = stdout + os.Stderr = stderr + i.m.Unlock() + + return entry +} + +func signatureSource(srcRef name.Reference, tag string) string { + repo := srcRef.Context().RepositoryStr() + if upstream, found := externalImages[repo]; found { + return fmt.Sprintf("%s:%s", upstream, tag) + } + + // Fully qualified reference: /: + return fmt.Sprintf("%s:%s", srcRef.Context().Name(), tag) +} + +func copySignature(ctx context.Context, srcImgRef, dstImgRef string, copyImage bool) error { + fmt.Println(srcImgRef, dstImgRef) + if copyImage { + err := crane.Copy(srcImgRef, dstImgRef, + crane.WithContext(ctx), + crane.WithNoClobber(true)) // ensure tags won't be overwritten. + + if err != nil && !strings.Contains(err.Error(), "refusing to clobber existing tag") { + return fmt.Errorf("failed to copy image from %q to %q: %w", + srcImgRef, dstImgRef, err) + } + } + + digest, err := crane.Digest(srcImgRef) + if err != nil { + return fmt.Errorf("failed to get signed image digest for %q: %w", srcImgRef, err) + } + + sourceRef, err := name.ParseReference(srcImgRef) + if err != nil { + return fmt.Errorf("failed to parse source image reference: %w", err) + } + + hex := strings.TrimPrefix(digest, "sha256:") + signatureTag := fmt.Sprintf("sha256-%s.sig", hex) + sourceSigRef := signatureSource(sourceRef, signatureTag) + + targetRef, err := name.ParseReference(dstImgRef) + if err != nil { + return fmt.Errorf("failed to parse target image reference: %w", err) + } + + dstSigRef := fmt.Sprintf("%s:%s", targetRef.Context().Name(), signatureTag) + err = crane.Copy(sourceSigRef, dstSigRef, + crane.WithContext(ctx), + crane.WithNoClobber(true), // ensure existing signatures won't be overwritten. + ) + if err != nil && !strings.Contains(err.Error(), "refusing to clobber existing tag") { + return fmt.Errorf("failed to copy signature from %q to %q: %w", sourceSigRef, dstSigRef, err) + } + + return nil +} diff --git a/internal/imagelist/copier_test.go b/internal/imagelist/copier_test.go new file mode 100644 index 0000000..7bb6a92 --- /dev/null +++ b/internal/imagelist/copier_test.go @@ -0,0 +1,84 @@ +package imagelist + +import ( + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOverrideSignatureSource(t *testing.T) { + t.Parallel() + + tests := []struct { + image string + want string + }{ + { + image: "rancher/rancher", + want: "index.docker.io/rancher/rancher", + }, + { + image: "127.0.0.1:5000/sig-storage/snapshot-controller", + want: "registry.k8s.io/sig-storage/snapshot-controller", + }, + { + image: "127.0.0.1:5000/sig-storage/snapshot-validation-webhook", + want: "registry.k8s.io/sig-storage/snapshot-validation-webhook", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-sig-storage-csi-node-driver-registrar", + want: "registry.k8s.io/sig-storage/csi-node-driver-registrar", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-sig-storage-csi-attacher", + want: "registry.k8s.io/sig-storage/csi-attacher", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-sig-storage-csi-provisioner", + want: "registry.k8s.io/sig-storage/csi-provisioner", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-sig-storage-csi-resizer", + want: "registry.k8s.io/sig-storage/csi-resizer", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-sig-storage-csi-snapshotter", + want: "registry.k8s.io/sig-storage/csi-snapshotter", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-sig-storage-livenessprobe", + want: "registry.k8s.io/sig-storage/rancher/mirrored-sig-storage-livenessprobe", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-sig-storage-snapshot-controller", + want: "registry.k8s.io/sig-storage/snapshot-controller", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-longhornio-csi-attacher", + want: "registry.k8s.io/sig-storage/csi-attacher", + }, + { + image: "127.0.0.1:5000/rancher/appco-redis", + want: "dp.apps.rancher.io/containers/redis", + }, + { + image: "127.0.0.1:5000/rancher/mirrored-cilium-cilium", + want: "quay.io/cilium/cilium", + }, + } + + for _, tc := range tests { + t.Run(tc.image, func(t *testing.T) { + t.Parallel() + + tag := "sha256-aabbeedd.sig" + srcRef, err := name.ParseReference(tc.image + ":" + tag) + require.NoError(t, err) + + got := signatureSource(srcRef, tag) + assert.Equal(t, tc.want+":"+tag, got) + }) + } +} diff --git a/internal/imagelist/imagelist.go b/internal/imagelist/imagelist.go index 8d84c16..a68633d 100644 --- a/internal/imagelist/imagelist.go +++ b/internal/imagelist/imagelist.go @@ -20,8 +20,29 @@ var ( const maxProcessingSizeInBytes = 5 * (1 << 20) // 5MB +type ImageVerifier interface { + Verify(img string) Entry +} + +type ImageCopier interface { + Copy(img, targetRegistry string) Entry +} + +type Result struct { + Product string `json:"product,omitempty"` + Version string `json:"version,omitempty"` + Entries []Entry `json:"entries,omitempty"` +} + +type Entry struct { + Image string `json:"image,omitempty"` + Error error `json:"error,omitempty"` + Signed bool `json:"signed,omitempty"` +} + type Processor struct { - ip ImageProcessor + ip ImageVerifier + copier ImageCopier fetcher Fetcher registry string } @@ -31,14 +52,31 @@ func NewProcessor(registry string) *Processor { registry = registry + "/" } + copier := &imageCopier{ + mirroredOnly: true, + } + return &Processor{ registry: registry, ip: new(imageVerifier), fetcher: new(HttpFetcher), + copier: copier, } } func (p *Processor) Verify(url string) (*Result, error) { + return p.process(url, "Verify images", "", func(img, _ string) Entry { + return p.ip.Verify(img) + }) +} + +func (p *Processor) Copy(url, dstRegistry string) (*Result, error) { + return p.process(url, "Copy images", dstRegistry, func(img, dstRegistry string) Entry { + return p.copier.Copy(img, dstRegistry) + }) +} + +func (p *Processor) process(url, status, dstRegistry string, action func(string, string) Entry) (*Result, error) { url = strings.TrimSpace(url) if len(url) == 0 { return nil, ErrURLCannotBeEmpty @@ -65,7 +103,7 @@ func (p *Processor) Verify(url string) (*Result, error) { scanner := bufio.NewScanner(io.LimitReader(r, maxProcessingSizeInBytes)) - s = spinner.New("Verify images") + s = spinner.New(status) s.Start() for scanner.Scan() { @@ -88,7 +126,7 @@ func (p *Processor) Verify(url string) (*Result, error) { s.UpdateStatus(image) - entry := p.ip.Verify(image) + entry := action(image, dstRegistry) result.Entries = append(result.Entries, entry) } @@ -105,15 +143,3 @@ func (p *Processor) Verify(url string) (*Result, error) { return &result, nil } - -type Result struct { - Product string `json:"product,omitempty"` - Version string `json:"version,omitempty"` - Entries []Entry `json:"entries,omitempty"` -} - -type Entry struct { - Image string `json:"image,omitempty"` - Error error `json:"error,omitempty"` - Signed bool `json:"signed,omitempty"` -} diff --git a/internal/imagelist/verifier.go b/internal/imagelist/verifier.go index 1618e7d..9a90ccb 100644 --- a/internal/imagelist/verifier.go +++ b/internal/imagelist/verifier.go @@ -7,10 +7,6 @@ import ( "github.com/rancherlabs/slsactl/pkg/verify" ) -type ImageProcessor interface { - Verify(img string) Entry -} - type imageVerifier struct { m sync.Mutex } diff --git a/internal/product/common.go b/internal/product/common.go index ebf3f17..9054325 100644 --- a/internal/product/common.go +++ b/internal/product/common.go @@ -1,10 +1,14 @@ package product import ( + "encoding/json" "errors" "fmt" + "os" "regexp" "strings" + + "github.com/rancherlabs/slsactl/internal/imagelist" ) var ( @@ -58,3 +62,42 @@ func product(name, version string) (*productInfo, error) { return &info, nil } + +func saveOutput(fn string, result *imagelist.Result) error { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("fail to marshal JSON: %w", err) + } + + err = os.WriteFile(fn, data, 0o600) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } else { + fmt.Printf("\nreport saved as %q\n", fn) + } + + return nil +} + +func resultSummary(result *imagelist.Result) map[string]*summary { + s := map[string]*summary{} + for _, entry := range result.Entries { + imgType := "rancher" + if strings.Contains(entry.Image, "rancher/mirrored") { + imgType = "third-party" + } + + if _, ok := s[imgType]; !ok { + s[imgType] = &summary{} + } + + s[imgType].count++ + if entry.Signed { + s[imgType].signed++ + } + if entry.Error != nil { + s[imgType].errors++ + } + } + return s +} diff --git a/internal/product/copy.go b/internal/product/copy.go new file mode 100644 index 0000000..f083b9a --- /dev/null +++ b/internal/product/copy.go @@ -0,0 +1,61 @@ +package product + +import ( + "fmt" + "log/slog" + "os" + "text/tabwriter" + + "github.com/rancherlabs/slsactl/internal/imagelist" +) + +func Copy(registry, name, version, targetRegistry string) error { + info, err := product(name, version) + if err != nil { + return err + } + + fmt.Printf("Copying %s %s signatures to %q:\n\n", info.description, version, targetRegistry) + + p := imagelist.NewProcessor(registry) + result, err := p.Copy(fmt.Sprintf(info.imagesUrl, version), targetRegistry) + if err != nil { + return err + } + + result.Product = name + result.Version = version + + if len(info.windowsImagesUrl) > 0 { + r2, err := p.Copy(fmt.Sprintf(info.windowsImagesUrl, version), targetRegistry) + if err == nil { + result.Entries = append(result.Entries, r2.Entries...) + } else { + slog.Error("failed to process windows images", "error", err) + } + } + + err = printCopySummary(result) + if err != nil { + return fmt.Errorf("failed to print summary: %w", err) + } + + fn := fmt.Sprintf("%s_%s_copy.json", result.Product, result.Version) + return saveOutput(fn, result) +} + +func printCopySummary(result *imagelist.Result) error { + w := new(tabwriter.Writer) + w.Init(os.Stdout, 12, 12, 4, ' ', 0) + + fmt.Print("\n\n ✨ COPY SUMMARY ✨ \n") + fmt.Fprintln(w, "Image Type\tImages Count\tSignatures") + fmt.Fprintln(w, "-----------\t------------\t------------") + + s := resultSummary(result) + for name, data := range s { + fmt.Fprintf(w, "%s\t%d \t%d\n", name, data.count, data.signed) + } + + return w.Flush() +} diff --git a/internal/product/verify.go b/internal/product/verify.go index 8754ad7..e735ff1 100644 --- a/internal/product/verify.go +++ b/internal/product/verify.go @@ -1,11 +1,9 @@ package product import ( - "encoding/json" "fmt" "log/slog" "os" - "strings" "text/tabwriter" "github.com/rancherlabs/slsactl/internal/imagelist" @@ -45,7 +43,8 @@ func Verify(registry, name, version string, summary bool, outputFile bool) error } if outputFile { - return savePrintOutput(result) + fn := fmt.Sprintf("%s_%s.json", result.Product, result.Version) + return saveOutput(fn, result) } return nil @@ -55,50 +54,14 @@ func printVerifySummary(result *imagelist.Result) error { w := new(tabwriter.Writer) w.Init(os.Stdout, 12, 12, 4, ' ', 0) - s := map[string]*summary{} - for _, entry := range result.Entries { - imgType := "rancher" - if strings.Contains(entry.Image, "rancher/mirrored") { - imgType = "third-party" - } - - if _, ok := s[imgType]; !ok { - s[imgType] = &summary{} - } - - s[imgType].count++ - if entry.Signed { - s[imgType].signed++ - } - if entry.Error != nil { - s[imgType].errors++ - } - } - fmt.Print("\n\n ✨ VERIFICATION SUMMARY ✨ \n") fmt.Fprintln(w, "Image Type\tSigned images") fmt.Fprintln(w, "-----------\t--------------") + s := resultSummary(result) for name, data := range s { fmt.Fprintf(w, "%s\t%d (%d)\n", name, data.signed, data.count) } return w.Flush() } - -func savePrintOutput(result *imagelist.Result) error { - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("fail to marshal JSON: %w", err) - } - - fn := fmt.Sprintf("%s_%s.json", result.Product, result.Version) - err = os.WriteFile(fn, data, 0o600) - if err != nil { - return fmt.Errorf("failed to write file: %w", err) - } else { - fmt.Printf("\nreport saved as %q\n", fn) - } - - return nil -}