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..b0c7150 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" ) @@ -19,11 +21,14 @@ 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 -func NewCommand() *cobra.Command { +func NewCommand(clientId docker.DockerClientId) *cobra.Command { cmd := &cobra.Command{ Use: "release", @@ -36,6 +41,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..22fb7a6 --- /dev/null +++ b/cmd/skpr/release/pull/command.go @@ -0,0 +1,40 @@ +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 'pull' 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 release images.", + 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()) + }, + } + + 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 new file mode 100644 index 0000000..e5ded62 --- /dev/null +++ b/internal/command/release/pull/command.go @@ -0,0 +1,129 @@ +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 release images. +type Command struct { + Params Params + ClientId docker.DockerClientId +} + +// Params provided to this command. +type Params struct { + Name string + Service 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) + } + + writer := uilive.New() + writer.Start() + defer writer.Stop() + + 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") + } + + 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") + } + + 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 +}