From 03473812fba3d2c2df0ff49a4542dbd94fe5470c Mon Sep 17 00:00:00 2001 From: Nick Schuch Date: Tue, 24 Mar 2026 08:48:34 +1000 Subject: [PATCH 1/6] Initial command to pull a release image --- cmd/skpr/main.go | 2 +- cmd/skpr/release/command.go | 5 +- cmd/skpr/release/pull/command.go | 39 +++++++ internal/command/release/pull/command.go | 123 +++++++++++++++++++++++ 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 cmd/skpr/release/pull/command.go create mode 100644 internal/command/release/pull/command.go diff --git a/cmd/skpr/main.go b/cmd/skpr/main.go index c95e94b..7374e88 100644 --- a/cmd/skpr/main.go +++ b/cmd/skpr/main.go @@ -109,7 +109,7 @@ func main() { cmd.AddCommand(mysql.NewCommand(featureFlags.DockerClient)) cmd.AddCommand(pkg.NewCommand(featureFlags.DockerClient)) cmd.AddCommand(purge.NewCommand()) - cmd.AddCommand(release.NewCommand()) + cmd.AddCommand(release.NewCommand(featureFlags.DockerClient)) cmd.AddCommand(restore.NewCommand()) cmd.AddCommand(rsync.NewCommand()) cmd.AddCommand(shell.NewCommand()) diff --git a/cmd/skpr/release/command.go b/cmd/skpr/release/command.go index 93fe67f..b49be80 100644 --- a/cmd/skpr/release/command.go +++ b/cmd/skpr/release/command.go @@ -5,6 +5,8 @@ import ( "github.com/skpr/cli/cmd/skpr/release/info" "github.com/skpr/cli/cmd/skpr/release/list" + "github.com/skpr/cli/cmd/skpr/release/pull" + "github.com/skpr/cli/containers/docker" skprcommand "github.com/skpr/cli/internal/command" ) @@ -23,7 +25,7 @@ var ( ) // NewCommand creates a new cobra.Command for 'releases' sub command -func NewCommand() *cobra.Command { +func NewCommand(clientId docker.DockerClientId) *cobra.Command { cmd := &cobra.Command{ Use: "release", @@ -36,6 +38,7 @@ func NewCommand() *cobra.Command { cmd.AddCommand(info.NewCommand()) cmd.AddCommand(list.NewCommand()) + cmd.AddCommand(pull.NewCommand(clientId)) return cmd } diff --git a/cmd/skpr/release/pull/command.go b/cmd/skpr/release/pull/command.go new file mode 100644 index 0000000..4f429f6 --- /dev/null +++ b/cmd/skpr/release/pull/command.go @@ -0,0 +1,39 @@ +package pull + +import ( + "github.com/spf13/cobra" + + "github.com/skpr/cli/containers/docker" + + v1pull "github.com/skpr/cli/internal/command/release/pull" +) + +var ( + cmdLong = `Pulls the packaged container images for a release.` + + cmdExample = ` + # Pull the packaged container images for a release. + skpr release pull VERSION` +) + +// NewCommand creates a new cobra.Command for 'list' sub command +func NewCommand(clientId docker.DockerClientId) *cobra.Command { + command := v1pull.Command{} + + cmd := &cobra.Command{ + Use: "pull ...", + Args: cobra.ExactArgs(1), + DisableFlagsInUseLine: true, + Short: "Pull the packaged container images for a release.", + Long: cmdLong, + Example: cmdExample, + RunE: func(cmd *cobra.Command, args []string) error { + command.Params.Name = args[0] + command.ClientId = clientId + + return command.Run(cmd.Context()) + }, + } + + return cmd +} diff --git a/internal/command/release/pull/command.go b/internal/command/release/pull/command.go new file mode 100644 index 0000000..8d5cb4b --- /dev/null +++ b/internal/command/release/pull/command.go @@ -0,0 +1,123 @@ +package pull + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/gosuri/uilive" + "github.com/pkg/errors" + "github.com/skpr/api/pb" + + "github.com/skpr/cli/containers/buildpack/utils/aws/ecr" + "github.com/skpr/cli/containers/docker" + "github.com/skpr/cli/containers/docker/types" + "github.com/skpr/cli/internal/client" + skprlog "github.com/skpr/cli/internal/log" +) + +// Command to pull a database image. +type Command struct { + Params Params + ClientId docker.DockerClientId +} + +// Params provided to this command. +type Params struct { + Name string +} + +// Run the command. +func (cmd *Command) Run(ctx context.Context) error { + ctx, client, err := client.New(ctx) + if err != nil { + return err + } + + prettyHandler := skprlog.NewHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + AddSource: false, + ReplaceAttr: nil, + }) + + logger := slog.New(prettyHandler) + + release, err := client.Release().Info(ctx, &pb.ReleaseInfoRequest{ + Name: cmd.Params.Name, + }) + if err != nil { + return fmt.Errorf("could not get release: %w", err) + } + + for _, image := range release.Images { + repository, tag, err := ParseImage(image.URI) + if err != nil { + return errors.Wrap(err, "failed to parse image reference") + } + + auth := types.Auth{ + Username: client.Credentials.Username, + Password: client.Credentials.Password, + Session: client.Credentials.Session, + } + + // @todo, Consider abstracting this if another registry + credentials pair is required. + if ecr.IsRegistry(repository) { + auth, err = ecr.UpgradeAuth(ctx, repository, auth) + if err != nil { + return errors.Wrap(err, "failed to upgrade AWS ECR authentication") + } + } + + c, err := docker.NewClientFromUserConfig(auth, cmd.ClientId) + if err != nil { + return errors.Wrap(err, "failed to create Docker client") + } + + writer := uilive.New() + writer.Start() + defer writer.Stop() + + logger.Info(fmt.Sprintf("Pulling: %s", image.URI)) + + err = c.PullImage(ctx, repository, tag, writer) + if err != nil { + return err + } + + logger.Info(fmt.Sprintf("Successfully pulled image: %s", image.URI)) + } + + return nil +} + +func ParseImage(image string) (repository string, tag string, err error) { + if image == "" { + return "", "", fmt.Errorf("image reference is empty") + } + + // Reject digest references explicitly + if strings.Contains(image, "@") { + return "", "", fmt.Errorf("digest references are not supported") + } + + // Split on the last colon to preserve registry ports + lastColon := strings.LastIndex(image, ":") + if lastColon == -1 { + return "", "", fmt.Errorf("image reference does not contain a tag") + } + + repository = image[:lastColon] + tag = image[lastColon+1:] + + if repository == "" { + return "", "", fmt.Errorf("repository is empty") + } + if tag == "" { + return "", "", fmt.Errorf("tag is empty") + } + + return repository, tag, nil +} From 11ce3398c6437e821712a441d686af433f9d9c47 Mon Sep 17 00:00:00 2001 From: Nathan ter Bogt Date: Thu, 2 Apr 2026 10:38:38 +1300 Subject: [PATCH 2/6] Small quality of life improvements --- cmd/skpr/release/pull/command.go | 7 ++++--- internal/command/release/pull/command.go | 8 +++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/cmd/skpr/release/pull/command.go b/cmd/skpr/release/pull/command.go index 4f429f6..cfdf93f 100644 --- a/cmd/skpr/release/pull/command.go +++ b/cmd/skpr/release/pull/command.go @@ -4,7 +4,6 @@ import ( "github.com/spf13/cobra" "github.com/skpr/cli/containers/docker" - v1pull "github.com/skpr/cli/internal/command/release/pull" ) @@ -21,10 +20,10 @@ func NewCommand(clientId docker.DockerClientId) *cobra.Command { command := v1pull.Command{} cmd := &cobra.Command{ - Use: "pull ...", + Use: "pull ", Args: cobra.ExactArgs(1), DisableFlagsInUseLine: true, - Short: "Pull the packaged container images for a release.", + Short: "Pull release images.", Long: cmdLong, Example: cmdExample, RunE: func(cmd *cobra.Command, args []string) error { @@ -35,5 +34,7 @@ func NewCommand(clientId docker.DockerClientId) *cobra.Command { }, } + cmd.Flags().StringVar(&command.Params.Service, "service", command.Params.Service, "A specific service image to pull") + return cmd } diff --git a/internal/command/release/pull/command.go b/internal/command/release/pull/command.go index 8d5cb4b..dd4c915 100644 --- a/internal/command/release/pull/command.go +++ b/internal/command/release/pull/command.go @@ -26,7 +26,8 @@ type Command struct { // Params provided to this command. type Params struct { - Name string + Name string + Service string } // Run the command. @@ -52,6 +53,11 @@ func (cmd *Command) Run(ctx context.Context) error { } for _, image := range release.Images { + if cmd.Params.Service != "" { + if image.Name != cmd.Params.Service { + continue + } + } repository, tag, err := ParseImage(image.URI) if err != nil { return errors.Wrap(err, "failed to parse image reference") From d7819e2d1e4eb13ffbeae637e4afceba088b0104 Mon Sep 17 00:00:00 2001 From: Nathan ter Bogt Date: Thu, 2 Apr 2026 10:43:40 +1300 Subject: [PATCH 3/6] Bad comment. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/command/release/pull/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/command/release/pull/command.go b/internal/command/release/pull/command.go index dd4c915..512b4a8 100644 --- a/internal/command/release/pull/command.go +++ b/internal/command/release/pull/command.go @@ -18,7 +18,7 @@ import ( skprlog "github.com/skpr/cli/internal/log" ) -// Command to pull a database image. +// Command to pull release images. type Command struct { Params Params ClientId docker.DockerClientId From 7672f9aa509810137a6eced4bbac271c2e0b2e8e Mon Sep 17 00:00:00 2001 From: Nathan ter Bogt Date: Thu, 2 Apr 2026 10:44:25 +1300 Subject: [PATCH 4/6] More copypasta Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/skpr/release/pull/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/skpr/release/pull/command.go b/cmd/skpr/release/pull/command.go index cfdf93f..22fb7a6 100644 --- a/cmd/skpr/release/pull/command.go +++ b/cmd/skpr/release/pull/command.go @@ -15,7 +15,7 @@ var ( skpr release pull VERSION` ) -// NewCommand creates a new cobra.Command for 'list' sub command +// NewCommand creates a new cobra.Command for 'pull' sub command func NewCommand(clientId docker.DockerClientId) *cobra.Command { command := v1pull.Command{} From b0decc26d403a6b9345a19bb39a7aa8a08f1b4db Mon Sep 17 00:00:00 2001 From: Nathan ter Bogt Date: Thu, 2 Apr 2026 10:48:50 +1300 Subject: [PATCH 5/6] Move the writer out of the loop --- internal/command/release/pull/command.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/command/release/pull/command.go b/internal/command/release/pull/command.go index 512b4a8..e5ded62 100644 --- a/internal/command/release/pull/command.go +++ b/internal/command/release/pull/command.go @@ -52,6 +52,10 @@ func (cmd *Command) Run(ctx context.Context) error { return fmt.Errorf("could not get release: %w", err) } + writer := uilive.New() + writer.Start() + defer writer.Stop() + for _, image := range release.Images { if cmd.Params.Service != "" { if image.Name != cmd.Params.Service { @@ -82,10 +86,6 @@ func (cmd *Command) Run(ctx context.Context) error { return errors.Wrap(err, "failed to create Docker client") } - writer := uilive.New() - writer.Start() - defer writer.Stop() - logger.Info(fmt.Sprintf("Pulling: %s", image.URI)) err = c.PullImage(ctx, repository, tag, writer) From 3fa3a5111d993e0bf2eadac024bd8244703af0d7 Mon Sep 17 00:00:00 2001 From: Nathan ter Bogt Date: Thu, 2 Apr 2026 10:52:17 +1300 Subject: [PATCH 6/6] Include the command in the parent help. Consider removing all the parent subcommand examples. The doco already has them --- cmd/skpr/release/command.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/skpr/release/command.go b/cmd/skpr/release/command.go index b49be80..b0c7150 100644 --- a/cmd/skpr/release/command.go +++ b/cmd/skpr/release/command.go @@ -21,7 +21,10 @@ var ( skpr release info 1.0.0 # Show information on a release in JSON format. - skpr release info 1.0.0 --json` + skpr release info 1.0.0 --json + + # Pull release images. + skpr release pull 1.0.0` ) // NewCommand creates a new cobra.Command for 'releases' sub command