Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use (
./tools/prow-job-executor
./tools/registration
./tools/release
./tools/image-mirror
./tools/secret-sync
./tools/yamlwrap
)
31 changes: 31 additions & 0 deletions tools/image-mirror/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package imagemirror

import (
"fmt"

"github.com/spf13/cobra"

"github.com/Azure/ARO-Tools/tools/image-mirror/sync"
)

func NewCommand() (*cobra.Command, error) {
cmd := &cobra.Command{
Use: "image-mirror",
Short: "Mirror container images to Azure Container Registry.",
SilenceUsage: true,
SilenceErrors: true,
}

commands := []func() (*cobra.Command, error){
sync.NewCommand,
}
for _, newCmd := range commands {
c, err := newCmd()
if err != nil {
return nil, fmt.Errorf("failed to create subcommand: %w", err)
}
cmd.AddCommand(c)
}

return cmd, nil
}
27 changes: 27 additions & 0 deletions tools/image-mirror/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package imagemirror

import (
"testing"
)

func TestNewCommand(t *testing.T) {
cmd, err := NewCommand()
if err != nil {
t.Fatalf("unexpected error creating command: %v", err)
}
if cmd.Use != "image-mirror" {
t.Errorf("expected Use 'image-mirror', got %q", cmd.Use)
}

// Verify the "sync" subcommand is registered
found := false
for _, sub := range cmd.Commands() {
if sub.Use == "sync" {
found = true
break
}
}
if !found {
t.Error("expected 'sync' subcommand not found")
}
}
13 changes: 13 additions & 0 deletions tools/image-mirror/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module github.com/Azure/ARO-Tools/tools/image-mirror

go 1.25.0

require (
github.com/go-logr/logr v1.4.3
github.com/spf13/cobra v1.10.2
)

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
)
13 changes: 13 additions & 0 deletions tools/image-mirror/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
56 changes: 56 additions & 0 deletions tools/image-mirror/sync/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package sync

import (
"fmt"
"log"
"os"
"os/signal"

"github.com/go-logr/logr"
"github.com/go-logr/logr/funcr"
"github.com/spf13/cobra"
)

// NewCommand creates the "sync" subcommand.
func NewCommand() (*cobra.Command, error) {
cmd := &cobra.Command{
Use: "sync",
Short: "Sync a container image to an Azure Container Registry.",
SilenceUsage: true,
SilenceErrors: true,
}

opts := DefaultOptions()
BindOptions(opts, cmd)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt)
defer cancel()

validated, err := opts.Validate()
if err != nil {
return fmt.Errorf("validation failed: %w", err)
}
completed, err := validated.Complete()
if err != nil {
return fmt.Errorf("completion failed: %w", err)
}

logger := newLogger()
runner := completed.NewRunner(logger)
return runner.Run(ctx)
}

return cmd, nil
}

func newLogger() logr.Logger {
return funcr.New(func(prefix, args string) {
if prefix != "" {
log.Printf("%s: %s", prefix, args)
} else {
log.Print(args)
}
}, funcr.Options{
Verbosity: 1,
})
}
37 changes: 37 additions & 0 deletions tools/image-mirror/sync/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package sync

import (
"testing"
)

func TestNewCommand(t *testing.T) {
cmd, err := NewCommand()
if err != nil {
t.Fatalf("unexpected error creating command: %v", err)
}
if cmd.Use != "sync" {
t.Errorf("expected Use 'sync', got %q", cmd.Use)
}

// Verify all expected flags exist
expectedFlags := []string{
"target-acr",
"source-registry",
"repository",
"digest",
"copy-from",
"image-file-path",
"image-tar-file",
"image-metadata-file",
"image-tar-sas",
"image-metadata-sas",
"pull-secret-kv",
"pull-secret",
"dry-run",
}
for _, flag := range expectedFlags {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("expected flag %q not found", flag)
}
}
}
146 changes: 146 additions & 0 deletions tools/image-mirror/sync/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package sync

import (
"fmt"

"github.com/go-logr/logr"
"github.com/spf13/cobra"
)

// RawOptions holds the raw CLI input values.
type RawOptions struct {
TargetACR string
SourceRegistry string
Repository string
Digest string
CopyFrom string
ImageFilePath string
ImageTarFileName string
ImageMetadataFileName string
ImageTarSAS string
ImageMetadataSAS string
PullSecretKV string
PullSecretName string
DryRun bool
}

// validatedOptions is a private wrapper that enforces a call of Validate() before Complete() can be invoked.
type validatedOptions struct {
*RawOptions
}

// ValidatedOptions wraps validatedOptions to enforce the Validate() -> Complete() flow.
type ValidatedOptions struct {
*validatedOptions
}

// completedOptions holds the finalized, ready-to-use options.
type completedOptions struct {
TargetACR string
SourceRegistry string
Repository string
Digest string
CopyFrom string
ImageFilePath string
ImageTarFileName string
ImageMetadataFileName string
ImageTarSAS string
ImageMetadataSAS string
PullSecretKV string
PullSecretName string
DryRun bool
}

// Options wraps completedOptions to enforce the Validate() -> Complete() -> Run() flow.
type Options struct {
*completedOptions
}

// DefaultOptions returns a new RawOptions with defaults.
func DefaultOptions() *RawOptions {
return &RawOptions{}
}

// BindOptions binds CLI flags to the raw options.
func BindOptions(opts *RawOptions, cmd *cobra.Command) {
cmd.Flags().StringVar(&opts.TargetACR, "target-acr", opts.TargetACR, "Target Azure Container Registry name.")
cmd.Flags().StringVar(&opts.SourceRegistry, "source-registry", opts.SourceRegistry, "Source container registry host (for registry copy mode).")
cmd.Flags().StringVar(&opts.Repository, "repository", opts.Repository, "Image repository name.")
cmd.Flags().StringVar(&opts.Digest, "digest", opts.Digest, "Image digest (e.g. sha256:...).")
cmd.Flags().StringVar(&opts.CopyFrom, "copy-from", opts.CopyFrom, "Copy mode: 'oci-layout' for file-based or empty for registry-based.")
cmd.Flags().StringVar(&opts.ImageFilePath, "image-file-path", opts.ImageFilePath, "Directory path containing image tar and metadata files.")
cmd.Flags().StringVar(&opts.ImageTarFileName, "image-tar-file", opts.ImageTarFileName, "Image tar file name for OCI layout mode.")
cmd.Flags().StringVar(&opts.ImageMetadataFileName, "image-metadata-file", opts.ImageMetadataFileName, "Image metadata JSON file name for OCI layout mode.")
cmd.Flags().StringVar(&opts.ImageTarSAS, "image-tar-sas", opts.ImageTarSAS, "SAS URL for downloading the image tar file.")
cmd.Flags().StringVar(&opts.ImageMetadataSAS, "image-metadata-sas", opts.ImageMetadataSAS, "SAS URL for downloading the image metadata file.")
cmd.Flags().StringVar(&opts.PullSecretKV, "pull-secret-kv", opts.PullSecretKV, "KeyVault name containing pull secret (for registry copy mode).")
cmd.Flags().StringVar(&opts.PullSecretName, "pull-secret", opts.PullSecretName, "Pull secret name in KeyVault (for registry copy mode).")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", opts.DryRun, "If true, validate inputs without making changes.")
}

// Validate validates the raw options.
func (o *RawOptions) Validate() (*ValidatedOptions, error) {
if o.TargetACR == "" {
return nil, fmt.Errorf("the target ACR must be provided with --target-acr")
}
if o.Repository == "" {
return nil, fmt.Errorf("the repository must be provided with --repository")
}

if o.CopyFrom == copyFromOCI {
if o.ImageTarFileName == "" {
return nil, fmt.Errorf("the image tar file name must be provided with --image-tar-file for oci-layout mode")
}
if o.ImageMetadataFileName == "" {
return nil, fmt.Errorf("the image metadata file name must be provided with --image-metadata-file for oci-layout mode")
}
} else {
if o.SourceRegistry == "" {
return nil, fmt.Errorf("the source registry must be provided with --source-registry for registry mode")
}
if o.Digest == "" {
return nil, fmt.Errorf("the digest must be provided with --digest for registry mode")
}
if o.PullSecretKV == "" {
return nil, fmt.Errorf("the pull secret KeyVault must be provided with --pull-secret-kv for registry mode")
}
if o.PullSecretName == "" {
return nil, fmt.Errorf("the pull secret name must be provided with --pull-secret for registry mode")
}
}
Comment on lines +90 to +110
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--copy-from accepts any arbitrary string today: if it’s non-empty but not oci-layout, validation falls back to “registry mode” and produces potentially confusing errors (e.g., demanding --source-registry). Consider explicitly validating that CopyFrom is either empty (registry mode) or exactly oci-layout, and returning a clear error for any other value.

Copilot uses AI. Check for mistakes.

return &ValidatedOptions{
validatedOptions: &validatedOptions{
RawOptions: o,
},
}, nil
}

// Complete builds the finalized options.
func (o *ValidatedOptions) Complete() (*Options, error) {
return &Options{
completedOptions: &completedOptions{
TargetACR: o.TargetACR,
SourceRegistry: o.SourceRegistry,
Repository: o.Repository,
Digest: o.Digest,
CopyFrom: o.CopyFrom,
ImageFilePath: o.ImageFilePath,
ImageTarFileName: o.ImageTarFileName,
ImageMetadataFileName: o.ImageMetadataFileName,
ImageTarSAS: o.ImageTarSAS,
ImageMetadataSAS: o.ImageMetadataSAS,
PullSecretKV: o.PullSecretKV,
PullSecretName: o.PullSecretName,
DryRun: o.DryRun,
},
}, nil
}

// NewRunner creates a runner from completed options.
func (o *Options) NewRunner(logger logr.Logger) *Runner {
return &Runner{
opts: o,
logger: logger,
}
}
Loading
Loading