diff --git a/rewrite/internal/cli/create.go b/rewrite/internal/cli/create.go index c7a5d6a6..16242572 100644 --- a/rewrite/internal/cli/create.go +++ b/rewrite/internal/cli/create.go @@ -208,6 +208,8 @@ func createAction(ctx context.Context, cmd *cli.Command) error { DryRun: cmd.Bool("dry-run"), GenerateEntry: !cmd.Bool("no-entry"), Rootful: cmd.Bool("root"), + ContainerAlwaysPull: cmd.Bool("pull"), + NonInteractive: cmd.Bool("yes"), } progress := ui.NewProgress(os.Stderr) @@ -220,6 +222,11 @@ func createAction(ctx context.Context, cmd *cli.Command) error { printContainerAlreadyExists(progress, containerAlreadyExistsErr.ContainerName, opts.Rootful) } + if errors.Is(err, commands.ErrImagePullAbortedByUser) { + progress.Finalize("next time, pull the image first") + return nil + } + if err != nil { return fmt.Errorf("create command failed: %w", err) } diff --git a/rewrite/pkg/commands/assemble.go b/rewrite/pkg/commands/assemble.go index 410dc579..a1db1962 100644 --- a/rewrite/pkg/commands/assemble.go +++ b/rewrite/pkg/commands/assemble.go @@ -134,11 +134,10 @@ func (ac *AssembleCommand) createItem(ctx context.Context, item manifest.Item, d GenerateEntry: item.Entry, Rootful: item.Root, DryRun: dryRun, + NonInteractive: true, + ContainerAlwaysPull: item.AlwaysPull, } - // TODO: pull image if needed - // https://github.com/89luca89/distrobox/blob/main/distrobox-create#L1016 - err := ac.createCmd.Execute(ctx, opts) if err != nil { ac.progress.Fail() diff --git a/rewrite/pkg/commands/create.go b/rewrite/pkg/commands/create.go index 0d7894d1..fa63f2e8 100644 --- a/rewrite/pkg/commands/create.go +++ b/rewrite/pkg/commands/create.go @@ -23,6 +23,7 @@ const ( ) var ErrHostnameTooLong = fmt.Errorf("hostname too long, must be less than %d characters", maxHostnameLength) +var ErrImagePullAbortedByUser = errors.New("image pull operation aborted by user") type ContainerAlreadyExistsError struct { ContainerName string @@ -36,6 +37,7 @@ type CreateCommand struct { containerManager containermanager.ContainerManager generateEntryCmd *GenerateEntryCommand progress *ui.Progress + prompter *ui.Prompter } type CreateOptions struct { @@ -77,6 +79,9 @@ type CreateOptions struct { GenerateEntry bool Rootful bool + + ContainerAlwaysPull bool + NonInteractive bool } func NewCreateCommand(cm containermanager.ContainerManager, progress *ui.Progress) *CreateCommand { @@ -109,8 +114,9 @@ func (c *CreateCommand) Execute(ctx context.Context, opts CreateOptions) error { containerImage = cloneImage } - // TODO: pull image if needed - // https://github.com/89luca89/distrobox/blob/main/distrobox-create#L1016 + if err := c.askPullImage(ctx, containerImage, opts); err != nil { + return err + } c.progress.Next("Creating '%s' using image %s", containerName, containerImage) @@ -268,3 +274,23 @@ func (c *CreateCommand) clone(ctx context.Context, containerName string) (string return commitTag, nil } + +func (c *CreateCommand) askPullImage(ctx context.Context, containerImage string, opts CreateOptions) error { + if opts.ContainerAlwaysPull || !c.containerManager.ImageExists(ctx, containerImage) { + skipConfirm := opts.NonInteractive || opts.ContainerAlwaysPull + if !skipConfirm { + msg := fmt.Sprintf("Image '%s' not found.\n. Do you want to pull the image now?", containerImage) + answer := c.prompter.Prompt(msg, true) + if !answer { + return ErrImagePullAbortedByUser + } + } + + err := c.containerManager.PullImage(ctx, containerImage, opts.ContainerPlatform) + if err != nil { + return fmt.Errorf("failed to pull image '%s': %w", containerImage, err) + } + } + + return nil +} diff --git a/rewrite/pkg/commands/ephemeral.go b/rewrite/pkg/commands/ephemeral.go index d928e9b5..8bac38e2 100644 --- a/rewrite/pkg/commands/ephemeral.go +++ b/rewrite/pkg/commands/ephemeral.go @@ -47,9 +47,7 @@ func (c *EphemeralCommand) Execute(ctx context.Context, opts EphemeralOptions) e // override options not relevant for creating ephemeral containers createOpts.GenerateEntry = false createOpts.DryRun = opts.DryRun - // TODO: pull image if needed - // The feature is still a todo in the CreateCommand. When implemented, - // remember to set it here as well. + createOpts.NonInteractive = true if err := c.createCmd.Execute(ctx, createOpts); err != nil { return fmt.Errorf("failed to create ephemeral container: %w", err) } diff --git a/rewrite/pkg/containermanager/containermanager.go b/rewrite/pkg/containermanager/containermanager.go index 2c573d63..5981e3c8 100644 --- a/rewrite/pkg/containermanager/containermanager.go +++ b/rewrite/pkg/containermanager/containermanager.go @@ -82,7 +82,9 @@ type ContainerManager interface { Create(ctx context.Context, opts CreateOptions) error Remove(ctx context.Context, containerName string, opts RmOptions) error Exists(ctx context.Context, containerName string) bool + ImageExists(ctx context.Context, imageName string) bool Stop(ctx context.Context, containerNames []string) error InspectContainer(ctx context.Context, containerName string) (*InspectResult, error) - Commit(ctx context.Context, containerID string, tag string) error + PullImage(ctx context.Context, imageName string, platform string) error + Commit(ctx context.Context, containerID string, imageTag string) error } diff --git a/rewrite/pkg/containermanager/providers/docker.go b/rewrite/pkg/containermanager/providers/docker.go index 9be6405a..3b2fe631 100644 --- a/rewrite/pkg/containermanager/providers/docker.go +++ b/rewrite/pkg/containermanager/providers/docker.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -57,6 +58,7 @@ type dockerContainer struct { type runOptions struct { DryRun bool Interactive bool + TailLogs bool } type inspectOutput struct { @@ -70,6 +72,11 @@ type inspectOutput struct { } `json:"Config"` } +type InspectImageOutput struct { + ID string `json:"ID"` + Architecture string `json:"Architecture"` +} + func (d *Docker) ListContainers(ctx context.Context) ([]containermanager.Container, error) { args := []string{"ps", "-a", "--no-trunc", "--format", "json"} out, err := d.run(ctx, args, runOptions{}) @@ -473,6 +480,11 @@ func (d *Docker) run(ctx context.Context, args []string, opts runOptions) (strin cmd.Stdout = &stdout cmd.Stderr = &stderr + if opts.TailLogs { + cmd.Stdout = io.MultiWriter(&stdout, os.Stdout) + cmd.Stderr = io.MultiWriter(&stderr, os.Stderr) + } + err := cmd.Run() if err != nil { captured := strings.TrimSpace(stderr.String()) @@ -683,6 +695,36 @@ func (d *Docker) InspectContainer(ctx context.Context, containerName string) (*c return &config, nil } +func (d *Docker) ImageExists(ctx context.Context, imageName string) bool { + args := []string{"inspect", "--type", "image", "--format", "json", imageName} + output, err := d.run(ctx, args, runOptions{}) + if err != nil { + return false + } + + var inspects []inspectOutput + if err := json.Unmarshal([]byte(output), &inspects); err != nil { + return false + } + + if len(inspects) == 0 { + return false + } + + return true +} + +func (d *Docker) PullImage(ctx context.Context, imageName string, platform string) error { + var args []string + if platform != "" { + args = []string{"pull", "--platform", platform, imageName} + } else { + args = []string{"pull", imageName} + } + _, err := d.run(ctx, args, runOptions{TailLogs: true}) + return err +} + func buildContainerPath(cleanPath bool, hostPath string, cfg *containermanager.InspectResult) string { standardPaths := []string{"/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"} diff --git a/rewrite/pkg/containermanager/providers/podman.go b/rewrite/pkg/containermanager/providers/podman.go index b524c24c..e5fce3e2 100644 --- a/rewrite/pkg/containermanager/providers/podman.go +++ b/rewrite/pkg/containermanager/providers/podman.go @@ -522,6 +522,36 @@ func (p *Podman) Enter( return nil } +func (p *Podman) ImageExists(ctx context.Context, imageName string) bool { + args := []string{"inspect", "--type", "image", "--format", "json", imageName} + output, err := p.run(ctx, args, runOptions{}) + if err != nil { + return false + } + + var inspects []inspectOutput + if err := json.Unmarshal([]byte(output), &inspects); err != nil { + return false + } + + if len(inspects) == 0 { + return false + } + + return true +} + +func (p *Podman) PullImage(ctx context.Context, imageName string, platform string) error { + var args []string + if platform != "" { + args = []string{"pull", "--platform", platform, imageName} + } else { + args = []string{"pull", imageName} + } + _, err := p.run(ctx, args, runOptions{TailLogs: true}) + return err +} + func (p *Podman) Remove( ctx context.Context, containerName string, diff --git a/rewrite/pkg/manifest/parse.go b/rewrite/pkg/manifest/parse.go index 5afe7c6c..91bae2d4 100644 --- a/rewrite/pkg/manifest/parse.go +++ b/rewrite/pkg/manifest/parse.go @@ -28,7 +28,7 @@ type Item struct { Nvidia bool InitHooks []string PreInitHooks []string - Pull bool + AlwaysPull bool Root bool StartNow bool UnshareGroups bool @@ -163,7 +163,7 @@ func sectionToItem(section *ini.Section) Item { //nolint:funlen // Function leng case "entry": item.Entry = parseBool(last) case "pull": - item.Pull = parseBool(last) + item.AlwaysPull = parseBool(last) case "root": item.Root = parseBool(last) case "start_now": diff --git a/rewrite/pkg/manifest/parse_test.go b/rewrite/pkg/manifest/parse_test.go index 5db8cc80..0846d961 100644 --- a/rewrite/pkg/manifest/parse_test.go +++ b/rewrite/pkg/manifest/parse_test.go @@ -35,7 +35,7 @@ start_now=true assert.Equal(t, "distrodev", parsed[0].Name) assert.Equal(t, "ubuntu:24.04", parsed[0].Image) - assert.True(t, parsed[0].Pull) + assert.True(t, parsed[0].AlwaysPull) assert.False(t, parsed[0].Init) assert.True(t, parsed[0].StartNow) } @@ -158,7 +158,7 @@ nvidia=true distrodev := parsed[0] assert.Equal(t, "distrodev", distrodev.Name) assert.Equal(t, "ubuntu:24.04", distrodev.Image) - assert.True(t, distrodev.Pull) + assert.True(t, distrodev.AlwaysPull) assert.False(t, distrodev.Init) assert.True(t, distrodev.StartNow) @@ -203,14 +203,14 @@ image=ubuntu:22.04 # this will override the included image ubuntu22 := parsed[0] assert.Equal(t, "ubuntu22", ubuntu22.Name) assert.Equal(t, "ubuntu:22.04", ubuntu22.Image) - assert.True(t, ubuntu22.Pull) + assert.True(t, ubuntu22.AlwaysPull) assert.True(t, ubuntu22.Root) // Check ubuntu24 ubuntu24 := parsed[1] assert.Equal(t, "ubuntu24", ubuntu24.Name) assert.Equal(t, "ubuntu:22.04", ubuntu24.Image) - assert.True(t, ubuntu24.Pull) + assert.True(t, ubuntu24.AlwaysPull) assert.True(t, ubuntu24.Root) } @@ -240,7 +240,7 @@ start_now=true assert.Equal(t, "distrodev", parsed[0].Name) assert.Equal(t, "ubuntu:24.04", parsed[0].Image) - assert.True(t, parsed[0].Pull) + assert.True(t, parsed[0].AlwaysPull) assert.False(t, parsed[0].Init) assert.True(t, parsed[0].StartNow) }