feat(create): pull on create (#39)

* feat(containermanager): add ExistsImage function

* feat(containermanager): add PullImage function

* feat(containermanager): pipe command output to stdout and stderr

* feat(create): allow image pull on distrobox create

* feat(ephemeral): allow image pulling

* feat(assemble): allow image pulling

* refactor(manifest): use AlwaysPull instead of Pull to explicit intent
This commit is contained in:
Emanuele De Cupis
2026-03-28 15:13:27 +01:00
committed by Alessio Biancalana
parent 8261771ab1
commit 1e1681e90a
9 changed files with 120 additions and 16 deletions
+7
View File
@@ -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)
}
+2 -3
View File
@@ -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()
+28 -2
View File
@@ -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
}
+1 -3
View File
@@ -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)
}
@@ -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
}
@@ -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"}
@@ -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,
+2 -2
View File
@@ -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":
+5 -5
View File
@@ -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)
}