feat(migrate): add distrobox migrate subcommand for v1→v2 containers

This commit is contained in:
Alessio Biancalana
2026-08-02 17:30:52 +02:00
parent d34399f3da
commit e793feccf8
9 changed files with 1405 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
package cli
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/config"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
func newMigrateCommand(cfg *config.Values) *cli.Command {
return &cli.Command{
Name: "migrate",
Usage: "migrate an old (v1) distrobox container to work with v2",
UsageText: `distrobox migrate [container-name | --all] [options]
Examples:
distrobox migrate my-box
distrobox migrate --all
distrobox migrate --dry-run my-box
distrobox migrate --force --yes my-box
Migrate containers created with distrobox v1 to work with v2.
This recreates the container with updated mount points for the v2 script locations.`,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "all",
Aliases: []string{"a"},
Usage: "migrate all distrobox containers that need it",
},
&cli.BoolFlag{
Name: "force",
Usage: "skip the already-migrated check and recreate anyway",
},
&cli.BoolFlag{
Name: "dry-run",
Aliases: []string{"d"},
Usage: "print the commands that would be executed, do nothing",
},
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"Y"},
Value: cfg.NonInteractive,
Usage: "non-interactive, do not ask for confirmation",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return migrateAction(ctx, cmd, cfg)
},
}
}
func migrateAction(ctx context.Context, cmd *cli.Command, cfg *config.Values) error {
containerManager, ok := ctx.Value(containerManagerKey).(containermanager.ContainerManager)
if !ok {
return errors.New("container manager not found in context")
}
all := cmd.Bool("all")
containerNames := cmd.Args().Slice()
// Fall back to default container name when neither --all nor positional args
// are given, matching stop/rm behavior.
if !all && len(containerNames) == 0 && cfg.ContainerName != "" {
containerNames = []string{cfg.ContainerName}
}
options := commands.MigrateOptions{
ContainerNames: containerNames,
All: all,
Force: cmd.Bool("force"),
DryRun: cmd.Bool("dry-run"),
NonInteractive: cmd.Bool("yes"),
}
printer := ui.NewPrinter(os.Stderr, true)
prompter := ui.NewPrompter(*bufio.NewReader(os.Stdin), os.Stdout)
migrateCmd := commands.NewMigrateCommand(cfg, containerManager, printer, prompter)
err := migrateCmd.Execute(ctx, options)
if errors.Is(err, commands.ErrEmptyContainerList) {
errPrinter := ui.NewPrinter(os.Stderr, true)
errPrinter.Println("No containers found.")
return nil
}
if err != nil {
return fmt.Errorf("failed to execute migrate command: %w", err)
}
return nil
}
+255
View File
@@ -0,0 +1,255 @@
package cli
import (
"context"
"testing"
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/pkg/config"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
// migrateSpyContainerManager is a minimal spy that records calls to the
// methods used by the migrate command. All other methods are no-ops.
type migrateSpyContainerManager struct {
stops [][]string
creates []containermanager.CreateOptions
removes []string
commits []string
inspectResult *containermanager.InspectResult
}
func (s *migrateSpyContainerManager) Name() string { return "spy" }
func (s *migrateSpyContainerManager) CloneAsRoot() containermanager.ContainerManager { return s }
func (s *migrateSpyContainerManager) Enter(_ context.Context, _ containermanager.EnterOptions, _ *ui.Progress, _ *ui.Printer) error {
return nil
}
func (s *migrateSpyContainerManager) ListContainers(_ context.Context) ([]containermanager.Container, error) {
return nil, nil
}
func (s *migrateSpyContainerManager) Create(_ context.Context, opts containermanager.CreateOptions) error {
s.creates = append(s.creates, opts)
return nil
}
func (s *migrateSpyContainerManager) Remove(_ context.Context, name string, _ containermanager.RmOptions) error {
s.removes = append(s.removes, name)
return nil
}
func (s *migrateSpyContainerManager) Exists(_ context.Context, _ string) bool { return true }
func (s *migrateSpyContainerManager) Stop(_ context.Context, names []string) error {
s.stops = append(s.stops, names)
return nil
}
func (s *migrateSpyContainerManager) InspectContainer(_ context.Context, _ string) (*containermanager.InspectResult, error) {
if s.inspectResult != nil {
return s.inspectResult, nil
}
return &containermanager.InspectResult{}, nil
}
func (s *migrateSpyContainerManager) Commit(_ context.Context, containerID string, _ string) error {
s.commits = append(s.commits, containerID)
return nil
}
func (s *migrateSpyContainerManager) ImageExists(_ context.Context, _ string) bool { return true }
func (s *migrateSpyContainerManager) PullImage(_ context.Context, _ string, _ string, _ bool) error {
return nil
}
// runMigrate runs the migrate subcommand with the given argv (starting from
// "migrate") against a spy container manager. It returns the spy so the
// caller can assert on recorded calls.
func runMigrate(t *testing.T, spy *migrateSpyContainerManager, argv ...string) {
t.Helper()
cfg := config.DefaultValues()
cfg.NonInteractive = true
cmd := newMigrateCommand(cfg)
// Override the Before hook to inject our spy instead of detecting a
// real container manager, matching the pattern in enter_internal_test.go.
cmd.Before = func(ctx context.Context, _ *cli.Command) (context.Context, error) {
return context.WithValue(ctx, containerManagerKey, spy), nil
}
root := &cli.Command{Commands: []*cli.Command{cmd}}
full := append([]string{"distrobox"}, argv...)
if err := root.Run(context.Background(), full); err != nil {
t.Fatalf("unexpected error running migrate: %v", err)
}
}
func TestNewMigrateCommand_HasFlags(t *testing.T) {
cfg := config.DefaultValues()
cmd := newMigrateCommand(cfg)
if cmd.Name != "migrate" {
t.Errorf("expected name 'migrate', got %q", cmd.Name)
}
flagNames := map[string]bool{
"all": false,
"force": false,
"dry-run": false,
"yes": false,
}
for _, flag := range cmd.Flags {
for name := range flagNames {
if flag.Names()[0] == name {
flagNames[name] = true
}
}
}
for name, found := range flagNames {
if !found {
t.Errorf("expected flag --%s to be defined", name)
}
}
}
func TestMigrateAction_NoContainerSpecified_ReturnsError(t *testing.T) {
t.Setenv("DBX_SCRIPTS_DIR", t.TempDir())
cfg := config.DefaultValues()
cfg.ContainerName = ""
cfg.NonInteractive = true
spy := &migrateSpyContainerManager{}
cmd := newMigrateCommand(cfg)
cmd.Before = func(ctx context.Context, _ *cli.Command) (context.Context, error) {
return context.WithValue(ctx, containerManagerKey, spy), nil
}
root := &cli.Command{Commands: []*cli.Command{cmd}}
args := []string{"distrobox", "migrate"}
err := root.Run(context.Background(), args)
if err == nil {
t.Fatal("expected error when no container specified and no --all")
}
}
func TestMigrateAction_DryRun(t *testing.T) {
t.Setenv("DBX_SCRIPTS_DIR", t.TempDir())
spy := &migrateSpyContainerManager{
inspectResult: &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "exited",
ContainerImage: "alpine:latest",
NetworkMode: "host",
IpcMode: "host",
PidMode: "host",
Env: []string{"HOME=/home/testuser"},
Cmd: []string{
"--verbose", "--name", "testuser", "--user", "1000",
"--group", "1000", "--home", "/home/testuser",
"--init", "0", "--nvidia", "0",
"--pre-init-hooks", "", "--additional-packages", "",
"--", "",
},
Mounts: []containermanager.MountInfo{
{Source: "/usr/lib/distrobox/distrobox-init", Destination: "/usr/bin/entrypoint"},
},
},
}
runMigrate(t, spy, "migrate", "--dry-run", "my-box")
// Dry run: no side effects
if len(spy.stops) != 0 {
t.Errorf("expected 0 Stop calls, got %d", len(spy.stops))
}
if len(spy.commits) != 0 {
t.Errorf("expected 0 Commit calls, got %d", len(spy.commits))
}
if len(spy.removes) != 0 {
t.Errorf("expected 0 Remove calls, got %d", len(spy.removes))
}
if len(spy.creates) != 0 {
t.Errorf("expected 0 Create calls, got %d", len(spy.creates))
}
}
func TestMigrateAction_V2Container_Skipped(t *testing.T) {
v2ScriptDir := t.TempDir()
t.Setenv("DBX_SCRIPTS_DIR", v2ScriptDir)
spy := &migrateSpyContainerManager{
inspectResult: &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "exited",
ContainerImage: "alpine:latest",
NetworkMode: "host",
IpcMode: "host",
PidMode: "host",
Env: []string{"HOME=/home/testuser"},
Mounts: []containermanager.MountInfo{
{Source: v2ScriptDir + "/distrobox-init", Destination: "/usr/bin/entrypoint"},
},
},
}
runMigrate(t, spy, "migrate", "my-box")
// Already migrated: no side effects
if len(spy.stops) != 0 {
t.Errorf("expected 0 Stop calls, got %d", len(spy.stops))
}
if len(spy.commits) != 0 {
t.Errorf("expected 0 Commit calls, got %d", len(spy.commits))
}
if len(spy.removes) != 0 {
t.Errorf("expected 0 Remove calls, got %d", len(spy.removes))
}
if len(spy.creates) != 0 {
t.Errorf("expected 0 Create calls, got %d", len(spy.creates))
}
}
func TestMigrateAction_ForceRecreates(t *testing.T) {
t.Setenv("USER", "testuser")
t.Setenv("HOME", "/home/testuser")
t.Setenv("SHELL", "/bin/sh")
v2ScriptDir := t.TempDir()
t.Setenv("DBX_SCRIPTS_DIR", v2ScriptDir)
spy := &migrateSpyContainerManager{
inspectResult: &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "exited",
ContainerImage: "alpine:latest",
NetworkMode: "host",
IpcMode: "host",
PidMode: "host",
Env: []string{"HOME=/home/testuser"},
Cmd: []string{
"--verbose", "--name", "testuser", "--user", "1000",
"--group", "1000", "--home", "/home/testuser",
"--init", "0", "--nvidia", "0",
"--pre-init-hooks", "", "--additional-packages", "",
"--", "",
},
Mounts: []containermanager.MountInfo{
{Source: v2ScriptDir + "/distrobox-init", Destination: "/usr/bin/entrypoint"},
},
},
}
runMigrate(t, spy, "migrate", "--force", "my-box")
// Force: should recreate even if already v2
if len(spy.commits) != 1 {
t.Errorf("expected 1 Commit call, got %d", len(spy.commits))
}
if len(spy.removes) != 1 {
t.Errorf("expected 1 Remove call, got %d", len(spy.removes))
}
if len(spy.creates) != 1 {
t.Errorf("expected 1 Create call, got %d", len(spy.creates))
}
}
+8
View File
@@ -187,6 +187,13 @@ func subcommands(cfg *config.Values) []*cli.Command {
withContainerManager,
)
migrate := cc.apply(
newMigrateCommand,
withSudoGuard,
withRoot,
withContainerManager,
)
return []*cli.Command{
assemble,
create,
@@ -194,6 +201,7 @@ func subcommands(cfg *config.Values) []*cli.Command {
ephemeral,
generateEntry,
list,
migrate,
rm,
stop,
upgrade,
+19
View File
@@ -80,3 +80,22 @@ func exists(name string) bool {
return false
}
// ScriptsDir returns the directory path where the distrobox scripts should be
// stored on the host. This is the fixed v2 location that containers are
// expected to bind-mount their entrypoint/export/host-exec scripts from.
func ScriptsDir() string {
// First check DBX_SCRIPTS_DIR env var
if dir := os.Getenv("DBX_SCRIPTS_DIR"); dir != "" {
return dir
}
// Then, check HOME env var
// v2 is added to avoid collisions with v1 installations
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, ".local", "share", "distrobox", "v2")
}
// Fallback to default path
return "/var/lib/distrobox/v2"
}
+386
View File
@@ -0,0 +1,386 @@
package commands
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
insidedistrobox "github.com/89luca89/distrobox/internal/inside-distrobox"
"github.com/89luca89/distrobox/internal/userenv"
"github.com/89luca89/distrobox/pkg/config"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
// ErrMigrateNoContainerSpecified is returned when no container name is given
// and --all is not set.
var ErrMigrateNoContainerSpecified = errors.New("please specify the name of the container to migrate")
// ErrMigrateAlreadyMigrated is returned (per-container) when a container
// already has v2 mountpoints and --force was not requested.
var ErrMigrateAlreadyMigrated = errors.New("container is already migrated to v2")
// MigrateOptions holds the options for the migrate command.
type MigrateOptions struct {
// ContainerNames is the explicit list of containers to migrate.
ContainerNames []string
// All migrates every distrobox container found.
All bool
// Force skips the "already migrated" check and recreates anyway.
Force bool
// DryRun prints the commands that would be executed without running them.
DryRun bool
// NonInteractive skips confirmation prompts.
NonInteractive bool
}
// MigrateCommand orchestrates the migration of v1 distrobox containers to v2.
type MigrateCommand struct {
cfg *config.Values
containerManager containermanager.ContainerManager
listCmd *ListCommand
printer *ui.Printer
prompter *ui.Prompter
}
// NewMigrateCommand creates a new MigrateCommand.
func NewMigrateCommand(
cfg *config.Values,
cm containermanager.ContainerManager,
printer *ui.Printer,
prompter *ui.Prompter,
) *MigrateCommand {
return &MigrateCommand{
cfg: cfg,
containerManager: cm,
listCmd: NewListCommand(cfg, cm),
printer: printer,
prompter: prompter,
}
}
// Execute runs the migration for the specified containers.
func (c *MigrateCommand) Execute(ctx context.Context, opts MigrateOptions) error {
var containerNames []string
switch {
case opts.All:
containers, err := c.listCmd.Execute(ctx)
if err != nil {
return fmt.Errorf("failed to list containers: %w", err)
}
if len(containers.Containers) == 0 {
return ErrEmptyContainerList
}
containerNames = make([]string, 0, len(containers.Containers))
for _, container := range containers.Containers {
containerNames = append(containerNames, container.Name)
}
case len(opts.ContainerNames) > 0:
containerNames = opts.ContainerNames
default:
return ErrMigrateNoContainerSpecified
}
var lastErr error
for _, name := range containerNames {
if err := c.migrateContainer(ctx, name, opts); err != nil {
if errors.Is(err, ErrMigrateAlreadyMigrated) {
c.printer.Println("Container '%s' is already migrated to v2, skipping.", name)
continue
}
c.printer.PrintErrorln("error migrating %s: %s", name, err)
lastErr = err
continue
}
}
return lastErr
}
// migrateContainer performs the migration for a single container.
func (c *MigrateCommand) migrateContainer(ctx context.Context, name string, opts MigrateOptions) error {
inspectResult, err := c.containerManager.InspectContainer(ctx, name)
if err != nil {
return fmt.Errorf("failed to inspect container %s: %w", name, err)
}
// Check if migration is needed
if !opts.Force {
if c.isAlreadyMigrated(inspectResult) {
return ErrMigrateAlreadyMigrated
}
}
// Confirm with the user unless non-interactive
if !opts.NonInteractive && !opts.DryRun && c.prompter != nil {
msg := fmt.Sprintf(
"Migrate container '%s'? This will stop, commit, remove and recreate it.",
name,
)
if !c.prompter.Prompt(msg, true) {
c.printer.Println("Skipping '%s'.", name)
return nil
}
}
c.printer.Println("Migrating '%s'...", name)
// Recover the original creation options from the inspect data
createOpts := c.recoverCreateOptions(ctx, name, inspectResult)
if opts.DryRun {
c.printer.Println("[dry-run] Would stop, commit, remove and recreate container '%s' with image '%s'", name, createOpts.ContainerImage)
return nil
}
// Step 1: Stop the container if running
if inspectResult.ContainerStatus == containermanager.RunningStatus {
c.printer.Println("Stopping '%s'...", name)
if err := c.containerManager.Stop(ctx, []string{name}); err != nil {
return fmt.Errorf("failed to stop container %s: %w", name, err)
}
}
// Step 2: Commit the container's filesystem to a temporary image
commitTag := fmt.Sprintf("%s:migrate-%s", strings.ToLower(name), time.Now().Format("2006-01-02"))
c.printer.Println("Committing container '%s' to image '%s'...", name, commitTag)
if err := c.containerManager.Commit(ctx, inspectResult.ContainerID, commitTag); err != nil {
return fmt.Errorf("failed to commit container %s: %w", name, err)
}
// Step 3: Remove the old container
c.printer.Println("Removing old container '%s'...", name)
if err := c.containerManager.Remove(ctx, name, containermanager.RmOptions{Force: true}); err != nil {
return fmt.Errorf("failed to remove old container %s: %w", name, err)
}
// Step 4: Provision v2 scripts (ensure they exist before creating)
if _, err := insidedistrobox.ProvisionScripts(insidedistrobox.ScriptsDir()); err != nil {
return fmt.Errorf("failed to provision v2 scripts: %w", err)
}
// Step 5: Recreate the container with the committed image and recovered options
createOpts.ContainerImage = commitTag
c.printer.Println("Recreating container '%s'...", name)
if err := c.containerManager.Create(ctx, createOpts); err != nil {
return fmt.Errorf("failed to recreate container %s: %w", name, err)
}
c.printer.Println("Container '%s' migrated successfully.", name)
return nil
}
// isAlreadyMigrated checks whether the container's entrypoint mount source
// already points to the v2 scripts directory.
func (c *MigrateCommand) isAlreadyMigrated(inspect *containermanager.InspectResult) bool {
v2Dir := insidedistrobox.ScriptsDir()
for _, mount := range inspect.Mounts {
if mount.Destination == "/usr/bin/entrypoint" {
// The source should be <v2Dir>/distrobox-init
expected := filepath.Join(v2Dir, "distrobox-init")
if mount.Source == expected {
return true
}
// Also accept if the source is within the v2 directory (e.g., symlink resolution)
if strings.HasPrefix(mount.Source, v2Dir) {
return true
}
return false
}
}
// If we can't find the entrypoint mount at all, conservatively say not migrated
return false
}
// recoverCreateOptions reconstructs the CreateOptions from the container's
// inspect data, so the recreated container matches the original as closely as
// possible.
//
//nolint:gocognit,funlen // imperative option reconstruction is inherently linear
func (c *MigrateCommand) recoverCreateOptions(
ctx context.Context,
name string,
inspect *containermanager.InspectResult,
) containermanager.CreateOptions {
opts := containermanager.CreateOptions{
ContainerName: name,
}
// Use the committed image as the container image (set later by caller)
opts.ContainerImage = inspect.ContainerImage
// Parse Cmd/Args to recover distrobox-init arguments
cmd := inspect.Cmd
// Recover --init, --nvidia, --pre-init-hooks, --additional-packages,
// --home, and the init hook (after --)
for i := 0; i < len(cmd); i++ {
arg := cmd[i]
switch arg {
case "--init":
if i+1 < len(cmd) {
opts.Init = cmd[i+1] == "1"
i++
}
case "--nvidia":
if i+1 < len(cmd) {
opts.Nvidia = cmd[i+1] == "1"
i++
}
case "--pre-init-hooks":
if i+1 < len(cmd) {
opts.ContainerPreInitHook = cmd[i+1]
i++
}
case "--additional-packages":
if i+1 < len(cmd) {
opts.AdditionalPackages = strings.Fields(cmd[i+1])
i++
}
case "--home":
if i+1 < len(cmd) {
home := cmd[i+1]
userEnv := userenv.LoadUserEnvironment(ctx)
// If the home differs from the user's real home, it's a custom home
if home != userEnv.Home {
opts.ContainerUserCustomHome = home
}
i++
}
case "--":
// Everything after -- is the init hook
if i+1 < len(cmd) {
opts.ContainerInitHook = strings.Join(cmd[i+1:], " ")
}
}
}
// Recover unshare flags from HostConfig modes
opts.UnshareNetNS = inspect.NetworkMode != "" && inspect.NetworkMode != "host"
opts.UnshareIPC = inspect.IpcMode != "" && inspect.IpcMode != "host"
opts.UnshareProcess = inspect.PidMode != "" && inspect.PidMode != "host"
// Recover unshare_groups from inspect (already parsed label)
opts.UnshareGroups = inspect.UnshareGroups
// Recover UnshareDevsys: check if /dev:/dev mount is present
opts.UnshareDevsys = true
for _, mount := range inspect.Mounts {
if mount.Source == "/dev" && mount.Destination == "/dev" {
opts.UnshareDevsys = false
break
}
}
// Recover container hostname from env
for _, env := range inspect.Env {
if strings.HasPrefix(env, "HOSTNAME=") {
hostname := strings.TrimPrefix(env, "HOSTNAME=")
// Only set if it's not the default (host hostname)
if h, err := os.Hostname(); err == nil && hostname != h {
opts.ContainerHostname = hostname
}
break
}
}
// Recover nopasswd from presence of /run/.nopasswd mount
for _, mount := range inspect.Mounts {
if mount.Destination == "/run/.nopasswd" {
opts.Nopasswd = true
break
}
}
// Recover additional user volumes: mounts that are not standard distrobox
// mounts and are bind-type (have a source that's an absolute path we can
// reconstruct as src:dst[:opts])
opts.AdditionalVolumes = c.recoverAdditionalVolumes(inspect.Mounts)
return opts
}
// recoverAdditionalVolumes extracts user-specified additional volumes from
// the mount list, filtering out standard distrobox mounts.
func (c *MigrateCommand) recoverAdditionalVolumes(mounts []containermanager.MountInfo) []string {
// Standard distrobox mount destinations that are managed by the create
// command and should not be treated as user volumes.
standardDestinations := map[string]bool{
"/usr/bin/entrypoint": true,
"/usr/bin/distrobox-export": true,
"/usr/bin/distrobox-host-exec": true,
"/tmp": true,
"/dev": true,
"/dev/pts": true,
"/dev/ptmx": true,
"/sys": true,
"/sys/fs/selinux": true,
"/var/log/journal": true,
"/run/.nopasswd": true,
"/run/.distrobox.rootless": true,
"/etc/hosts": true,
"/etc/resolv.conf": true,
"/etc/hostname": true,
}
// Standard source paths that are managed by distrobox
standardSources := map[string]bool{
"/dev/null": true,
"/dev": true,
"/sys": true,
"/tmp": true,
"/": true, // root mount for /run/host
}
var volumes []string
for _, mount := range mounts {
// Skip mounts with standard destinations
if standardDestinations[mount.Destination] {
continue
}
// Skip /run/host/* mounts (host root filesystem mounts)
if strings.HasPrefix(mount.Destination, "/run/host") {
continue
}
// Skip mounts where source is a standard system path and destination
// is also a system path
if standardSources[mount.Source] {
continue
}
// Skip user home mount (source == destination == $HOME)
if mount.Source == mount.Destination {
// This is likely the home directory mount or XDG_RUNTIME_DIR mount
// These are handled by create from the user environment
continue
}
// Skip mounts from the v2 scripts directory
v2Dir := insidedistrobox.ScriptsDir()
if strings.HasPrefix(mount.Source, v2Dir) {
continue
}
// Skip anonymous volumes (empty source = container-managed volume)
if mount.Source == "" {
continue
}
// This is a user-specified additional volume: reconstruct src:dst[:opts]
vol := mount.Source + ":" + mount.Destination
if mount.Options != "" {
// Filter out container-manager internal options, keep user-facing ones
vol += ":" + mount.Options
}
volumes = append(volumes, vol)
}
return volumes
}
+548
View File
@@ -0,0 +1,548 @@
package commands_test
import (
"bufio"
"context"
"io"
"path/filepath"
"strings"
"testing"
insidedistrobox "github.com/89luca89/distrobox/internal/inside-distrobox"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/config"
"github.com/89luca89/distrobox/pkg/containermanager"
testutil "github.com/89luca89/distrobox/pkg/internal/testutil"
"github.com/89luca89/distrobox/pkg/ui"
)
func newMigrateTestSetup(t *testing.T) (*commands.MigrateCommand, *testutil.MockContainerManager) {
t.Helper()
t.Setenv("USER", "testuser")
t.Setenv("HOME", "/home/testuser")
t.Setenv("SHELL", "/bin/sh")
// Use a temp directory for scripts to avoid touching the real HOME
t.Setenv("DBX_SCRIPTS_DIR", t.TempDir())
cfg := config.DefaultValues()
mock := &testutil.MockContainerManager{}
printer := ui.NewPrinter(io.Discard, false)
prompter := ui.NewPrompter(*bufio.NewReader(strings.NewReader("")), nil)
migrateCmd := commands.NewMigrateCommand(cfg, mock, printer, prompter)
return migrateCmd, mock
}
func TestMigrate_V1Container_TriggersStopCommitRemoveCreate(t *testing.T) {
migrateCmd, mock := newMigrateTestSetup(t)
// Simulate a v1 container: entrypoint mount source does NOT point to v2 dir
v1EntrypointPath := "/usr/lib/distrobox/distrobox-init"
ctx := context.Background()
mock.InspectContainerResult = &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "running",
ContainerImage: "quay.io/toolbx-images/alpine-toolbox:edge",
ContainerHome: "/home/testuser",
NetworkMode: "host",
IpcMode: "host",
PidMode: "host",
Env: []string{"HOME=/home/testuser", "HOSTNAME=testhost"},
Cmd: []string{
"--verbose",
"--name", "testuser",
"--user", "1000",
"--group", "1000",
"--home", "/home/testuser",
"--init", "0",
"--nvidia", "0",
"--pre-init-hooks", "",
"--additional-packages", " vim git",
"--", "",
},
Mounts: []containermanager.MountInfo{
{Source: v1EntrypointPath, Destination: "/usr/bin/entrypoint"},
{Source: "/usr/lib/distrobox/distrobox-export", Destination: "/usr/bin/distrobox-export"},
{Source: "/usr/lib/distrobox/distrobox-host-exec", Destination: "/usr/bin/distrobox-host-exec"},
{Source: "/home/testuser", Destination: "/home/testuser"},
{Source: "/tmp", Destination: "/tmp"},
{Source: "/dev", Destination: "/dev"},
},
}
opts := commands.MigrateOptions{
ContainerNames: []string{"my-box"},
NonInteractive: true,
}
err := migrateCmd.Execute(ctx, opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the sequence: Stop, Commit, Remove, Create
if len(mock.Spy.Stop) != 1 {
t.Errorf("expected 1 Stop call, got %d", len(mock.Spy.Stop))
}
stopArgs, _ := mock.Spy.Stop[0][0].([]string)
if len(stopArgs) != 1 || stopArgs[0] != "my-box" {
t.Errorf("expected Stop(['my-box']), got %v", stopArgs)
}
if len(mock.Spy.Commit) != 1 {
t.Errorf("expected 1 Commit call, got %d", len(mock.Spy.Commit))
}
commitContainerID, _ := mock.Spy.Commit[0][0].(string)
if commitContainerID != "abc123" {
t.Errorf("expected Commit('abc123', ...), got %s", commitContainerID)
}
if len(mock.Spy.Remove) != 1 {
t.Errorf("expected 1 Remove call, got %d", len(mock.Spy.Remove))
}
removeName, _ := mock.Spy.Remove[0][0].(string)
if removeName != "my-box" {
t.Errorf("expected Remove('my-box', ...), got %s", removeName)
}
if len(mock.Spy.Create) != 1 {
t.Errorf("expected 1 Create call, got %d", len(mock.Spy.Create))
}
createOpts, _ := mock.Spy.Create[0][0].(containermanager.CreateOptions)
if createOpts.ContainerName != "my-box" {
t.Errorf("expected ContainerName 'my-box', got %s", createOpts.ContainerName)
}
// The committed image tag should be used as the image
if createOpts.ContainerImage == "" {
t.Error("expected non-empty ContainerImage (committed tag)")
}
if createOpts.ContainerImage == "quay.io/toolbx-images/alpine-toolbox:edge" {
t.Error("expected committed image tag, not the original image")
}
}
func TestMigrate_V2Container_Skipped(t *testing.T) {
migrateCmd, mock := newMigrateTestSetup(t)
// Simulate a v2 container: entrypoint mount source points to v2 dir
v2Dir := insidedistrobox.ScriptsDir()
v2EntrypointPath := filepath.Join(v2Dir, "distrobox-init")
ctx := context.Background()
mock.InspectContainerResult = &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "exited",
ContainerImage: "alpine:latest",
Mounts: []containermanager.MountInfo{
{Source: v2EntrypointPath, Destination: "/usr/bin/entrypoint"},
},
}
opts := commands.MigrateOptions{
ContainerNames: []string{"my-box"},
NonInteractive: true,
}
err := migrateCmd.Execute(ctx, opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify no stop/commit/remove/create calls were made
if len(mock.Spy.Stop) != 0 {
t.Errorf("expected 0 Stop calls, got %d", len(mock.Spy.Stop))
}
if len(mock.Spy.Commit) != 0 {
t.Errorf("expected 0 Commit calls, got %d", len(mock.Spy.Commit))
}
if len(mock.Spy.Remove) != 0 {
t.Errorf("expected 0 Remove calls, got %d", len(mock.Spy.Remove))
}
if len(mock.Spy.Create) != 0 {
t.Errorf("expected 0 Create calls, got %d", len(mock.Spy.Create))
}
}
func TestMigrate_V2Container_ForceRecreates(t *testing.T) {
migrateCmd, mock := newMigrateTestSetup(t)
v2Dir := insidedistrobox.ScriptsDir()
v2EntrypointPath := filepath.Join(v2Dir, "distrobox-init")
ctx := context.Background()
mock.InspectContainerResult = &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "exited",
ContainerImage: "alpine:latest",
NetworkMode: "host",
IpcMode: "host",
PidMode: "host",
Env: []string{"HOME=/home/testuser"},
Cmd: []string{
"--verbose",
"--name", "testuser",
"--user", "1000",
"--group", "1000",
"--home", "/home/testuser",
"--init", "0",
"--nvidia", "0",
"--pre-init-hooks", "",
"--additional-packages", "",
"--", "",
},
Mounts: []containermanager.MountInfo{
{Source: v2EntrypointPath, Destination: "/usr/bin/entrypoint"},
{Source: filepath.Join(v2Dir, "distrobox-export"), Destination: "/usr/bin/distrobox-export"},
},
}
opts := commands.MigrateOptions{
ContainerNames: []string{"my-box"},
Force: true,
NonInteractive: true,
}
err := migrateCmd.Execute(ctx, opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// With --force, the container should be recreated (Commit + Remove + Create)
// Stop should NOT be called because the container is not running
if len(mock.Spy.Stop) != 0 {
t.Errorf("expected 0 Stop calls (container not running), got %d", len(mock.Spy.Stop))
}
if len(mock.Spy.Commit) != 1 {
t.Errorf("expected 1 Commit call, got %d", len(mock.Spy.Commit))
}
if len(mock.Spy.Remove) != 1 {
t.Errorf("expected 1 Remove call, got %d", len(mock.Spy.Remove))
}
if len(mock.Spy.Create) != 1 {
t.Errorf("expected 1 Create call, got %d", len(mock.Spy.Create))
}
}
func TestMigrate_DryRun_NoSideEffects(t *testing.T) {
migrateCmd, mock := newMigrateTestSetup(t)
// Use a v1 container (entrypoint not in v2 dir)
v1EntrypointPath := "/usr/lib/distrobox/distrobox-init"
ctx := context.Background()
mock.InspectContainerResult = &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "running",
ContainerImage: "alpine:latest",
NetworkMode: "host",
IpcMode: "host",
PidMode: "host",
Env: []string{"HOME=/home/testuser"},
Cmd: []string{
"--verbose",
"--name", "testuser",
"--user", "1000",
"--group", "1000",
"--home", "/home/testuser",
"--init", "0",
"--nvidia", "0",
"--pre-init-hooks", "",
"--additional-packages", "",
"--", "",
},
Mounts: []containermanager.MountInfo{
{Source: v1EntrypointPath, Destination: "/usr/bin/entrypoint"},
},
}
opts := commands.MigrateOptions{
ContainerNames: []string{"my-box"},
DryRun: true,
NonInteractive: true,
}
err := migrateCmd.Execute(ctx, opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Dry run: no side effects
if len(mock.Spy.Stop) != 0 {
t.Errorf("expected 0 Stop calls, got %d", len(mock.Spy.Stop))
}
if len(mock.Spy.Commit) != 0 {
t.Errorf("expected 0 Commit calls, got %d", len(mock.Spy.Commit))
}
if len(mock.Spy.Remove) != 0 {
t.Errorf("expected 0 Remove calls, got %d", len(mock.Spy.Remove))
}
if len(mock.Spy.Create) != 0 {
t.Errorf("expected 0 Create calls, got %d", len(mock.Spy.Create))
}
}
func TestMigrate_OptionReconstruction(t *testing.T) {
migrateCmd, mock := newMigrateTestSetup(t)
v1EntrypointPath := "/usr/lib/distrobox/distrobox-init"
ctx := context.Background()
mock.InspectContainerResult = &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "exited",
ContainerImage: "alpine:latest",
NetworkMode: "host", // not unshare-netns
IpcMode: "host", // not unshare-ipc
PidMode: "host", // not unshare-process
Env: []string{"HOME=/home/testuser"},
// Test Init=true, Nvidia=true, custom packages and hooks
Cmd: []string{
"--verbose",
"--name", "testuser",
"--user", "1000",
"--group", "1000",
"--home", "/home/testuser",
"--init", "1",
"--nvidia", "1",
"--pre-init-hooks", "echo hello",
"--additional-packages", "vim neovim",
"--", "echo world",
},
Mounts: []containermanager.MountInfo{
{Source: v1EntrypointPath, Destination: "/usr/bin/entrypoint"},
{Source: "/dev", Destination: "/dev"}, // UnshareDevsys = false
{Source: "/dev/null", Destination: "/run/.nopasswd"}, // Nopasswd = true
// Additional user volume
{Source: "/opt/myapp", Destination: "/usr/local/myapp", Options: "rbind"},
},
}
opts := commands.MigrateOptions{
ContainerNames: []string{"my-box"},
NonInteractive: true,
}
err := migrateCmd.Execute(ctx, opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(mock.Spy.Create) != 1 {
t.Fatalf("expected 1 Create call, got %d", len(mock.Spy.Create))
}
createOpts, _ := mock.Spy.Create[0][0].(containermanager.CreateOptions)
// Verify Init and Nvidia
if !createOpts.Init {
t.Error("expected Init=true")
}
if !createOpts.Nvidia {
t.Error("expected Nvidia=true")
}
// Verify pre-init hooks
if createOpts.ContainerPreInitHook != "echo hello" {
t.Errorf("expected pre-init hooks 'echo hello', got %q", createOpts.ContainerPreInitHook)
}
// Verify additional packages
if len(createOpts.AdditionalPackages) != 2 {
t.Errorf("expected 2 additional packages, got %d: %v", len(createOpts.AdditionalPackages), createOpts.AdditionalPackages)
}
if createOpts.AdditionalPackages[0] != "vim" || createOpts.AdditionalPackages[1] != "neovim" {
t.Errorf("expected [vim neovim], got %v", createOpts.AdditionalPackages)
}
// Verify init hook
if createOpts.ContainerInitHook != "echo world" {
t.Errorf("expected init hook 'echo world', got %q", createOpts.ContainerInitHook)
}
// Verify UnshareDevsys (false because /dev:/dev mount exists)
if createOpts.UnshareDevsys {
t.Error("expected UnshareDevsys=false")
}
// Verify unshare flags (all false because modes are "host")
if createOpts.UnshareNetNS {
t.Error("expected UnshareNetNS=false")
}
if createOpts.UnshareIPC {
t.Error("expected UnshareIPC=false")
}
if createOpts.UnshareProcess {
t.Error("expected UnshareProcess=false")
}
// Verify Nopasswd
if !createOpts.Nopasswd {
t.Error("expected Nopasswd=true")
}
// Verify additional volumes
foundVolume := false
for _, vol := range createOpts.AdditionalVolumes {
if vol == "/opt/myapp:/usr/local/myapp:rbind" {
foundVolume = true
break
}
}
if !foundVolume {
t.Errorf("expected additional volume '/opt/myapp:/usr/local/myapp:rbind' in %v", createOpts.AdditionalVolumes)
}
}
func TestMigrate_OptionReconstruction_UnshareFlags(t *testing.T) {
migrateCmd, mock := newMigrateTestSetup(t)
v1EntrypointPath := "/usr/lib/distrobox/distrobox-init"
ctx := context.Background()
mock.InspectContainerResult = &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "exited",
ContainerImage: "alpine:latest",
// Non-"host" modes indicate unshare flags
NetworkMode: "bridge",
IpcMode: "private",
PidMode: "container:other",
Env: []string{"HOME=/home/testuser"},
Cmd: []string{
"--verbose",
"--name", "testuser",
"--user", "1000",
"--group", "1000",
"--home", "/home/testuser",
"--init", "0",
"--nvidia", "0",
"--pre-init-hooks", "",
"--additional-packages", "",
"--", "",
},
Mounts: []containermanager.MountInfo{
{Source: v1EntrypointPath, Destination: "/usr/bin/entrypoint"},
// No /dev:/dev mount means UnshareDevsys = true
},
}
opts := commands.MigrateOptions{
ContainerNames: []string{"my-box"},
NonInteractive: true,
}
err := migrateCmd.Execute(ctx, opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(mock.Spy.Create) != 1 {
t.Fatalf("expected 1 Create call, got %d", len(mock.Spy.Create))
}
createOpts, _ := mock.Spy.Create[0][0].(containermanager.CreateOptions)
if !createOpts.UnshareNetNS {
t.Error("expected UnshareNetNS=true (NetworkMode=bridge)")
}
if !createOpts.UnshareIPC {
t.Error("expected UnshareIPC=true (IpcMode=private)")
}
if !createOpts.UnshareProcess {
t.Error("expected UnshareProcess=true (PidMode=container:other)")
}
if !createOpts.UnshareDevsys {
t.Error("expected UnshareDevsys=true (no /dev:/dev mount)")
}
}
func TestMigrate_OptionReconstruction_CustomHome(t *testing.T) {
migrateCmd, mock := newMigrateTestSetup(t)
v1EntrypointPath := "/usr/lib/distrobox/distrobox-init"
customHome := "/custom/home/mybox"
ctx := context.Background()
mock.InspectContainerResult = &containermanager.InspectResult{
ContainerID: "abc123",
ContainerStatus: "exited",
ContainerImage: "alpine:latest",
NetworkMode: "host",
IpcMode: "host",
PidMode: "host",
Env: []string{"HOME=/custom/home/mybox"},
Cmd: []string{
"--verbose",
"--name", "testuser",
"--user", "1000",
"--group", "1000",
"--home", customHome,
"--init", "0",
"--nvidia", "0",
"--pre-init-hooks", "",
"--additional-packages", "",
"--", "",
},
Mounts: []containermanager.MountInfo{
{Source: v1EntrypointPath, Destination: "/usr/bin/entrypoint"},
{Source: "/dev", Destination: "/dev"},
},
}
opts := commands.MigrateOptions{
ContainerNames: []string{"my-box"},
NonInteractive: true,
}
err := migrateCmd.Execute(ctx, opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(mock.Spy.Create) != 1 {
t.Fatalf("expected 1 Create call, got %d", len(mock.Spy.Create))
}
createOpts, _ := mock.Spy.Create[0][0].(containermanager.CreateOptions)
if createOpts.ContainerUserCustomHome != customHome {
t.Errorf("expected ContainerUserCustomHome %q, got %q", customHome, createOpts.ContainerUserCustomHome)
}
}
func TestMigrate_NoContainerSpecified(t *testing.T) {
migrateCmd, _ := newMigrateTestSetup(t)
ctx := context.Background()
opts := commands.MigrateOptions{}
err := migrateCmd.Execute(ctx, opts)
if err == nil {
t.Fatal("expected error when no container specified")
}
}
func TestMigrate_All_NoContainers(t *testing.T) {
migrateCmd, _ := newMigrateTestSetup(t)
ctx := context.Background()
opts := commands.MigrateOptions{
All: true,
}
err := migrateCmd.Execute(ctx, opts)
if err == nil {
t.Fatal("expected error when --all but no containers found")
}
}
+26
View File
@@ -79,6 +79,32 @@ type InspectResult struct {
ContainerHome string
ContainerPath string
UnshareGroups bool
// ContainerImage is the image name used to create the container
ContainerImage string
// Mounts are the bind mounts applied to the container
Mounts []MountInfo
// NetworkMode is the container's network mode (e.g., "host")
NetworkMode string
// IpcMode is the container's IPC mode (e.g., "host")
IpcMode string
// PidMode is the container's PID mode (e.g., "host")
PidMode string
// Cmd is the command (and args) the entrypoint was called with,
// i.e., the distrobox-init arguments for the container.
Cmd []string
// Env is the full list of environment variables set on the container
// (e.g., "HOME=/home/user", "HOSTNAME=...").
Env []string
}
// MountInfo represents a single bind mount of a container.
// Source is the host path, Destination is the in-container path,
// Options is a comma-separated list of mount options.
type MountInfo struct {
Source string
Destination string
Options string
}
type CreateOptions struct {
+38
View File
@@ -88,7 +88,21 @@ type inspectOutput struct {
Config struct {
Labels map[string]string `json:"Labels"`
Env []string `json:"Env"`
Cmd []string `json:"Cmd"`
} `json:"Config"`
ImageName string `json:"ImageName"`
Args []string `json:"Args"`
Mounts []struct {
Source string `json:"Source"`
Destination string `json:"Destination"`
Type string `json:"Type"`
Options []string `json:"Options"`
} `json:"Mounts"`
HostConfig struct {
NetworkMode string `json:"NetworkMode"`
IpcMode string `json:"IpcMode"`
PidMode string `json:"PidMode"`
} `json:"HostConfig"`
}
func (d *Docker) ListContainers(ctx context.Context) ([]containermanager.Container, error) {
@@ -695,6 +709,30 @@ func (d *Docker) InspectContainer(ctx context.Context, containerName string) (*c
inspect := inspects[0]
config.ContainerID = inspect.ID
config.ContainerStatus = inspect.State.Status
config.ContainerImage = inspect.ImageName
config.NetworkMode = inspect.HostConfig.NetworkMode
config.IpcMode = inspect.HostConfig.IpcMode
config.PidMode = inspect.HostConfig.PidMode
config.Env = inspect.Config.Env
// Docker exposes the entrypoint command (distrobox-init args) as Config.Cmd,
// not top-level Args (which is the parsed entrypoint path array). Prefer
// top-level Args if present (podman compatibility), else use Config.Cmd.
if len(inspect.Args) > 0 {
config.Cmd = inspect.Args
} else {
config.Cmd = inspect.Config.Cmd
}
// Populate mount info
config.Mounts = make([]containermanager.MountInfo, 0, len(inspect.Mounts))
for _, m := range inspect.Mounts {
config.Mounts = append(config.Mounts, containermanager.MountInfo{
Source: m.Source,
Destination: m.Destination,
Options: strings.Join(m.Options, ","),
})
}
// Check for unshare_groups label
if v, ok := inspect.Config.Labels["distrobox.unshare_groups"]; ok && v == "1" {
+25
View File
@@ -806,7 +806,32 @@ func (p *Podman) InspectContainer(ctx context.Context, containerName string) (*c
}
inspect := inspects[0]
config.ContainerID = inspect.ID
config.ContainerStatus = inspect.State.Status
config.ContainerImage = inspect.ImageName
config.NetworkMode = inspect.HostConfig.NetworkMode
config.IpcMode = inspect.HostConfig.IpcMode
config.PidMode = inspect.HostConfig.PidMode
config.Env = inspect.Config.Env
// Podman exposes the distrobox-init arguments as top-level Args.
// Docker exposes them as Config.Cmd. Prefer top-level Args if present,
// else fall back to Config.Cmd (for docker compatibility).
if len(inspect.Args) > 0 {
config.Cmd = inspect.Args
} else {
config.Cmd = inspect.Config.Cmd
}
// Populate mount info
config.Mounts = make([]containermanager.MountInfo, 0, len(inspect.Mounts))
for _, m := range inspect.Mounts {
config.Mounts = append(config.Mounts, containermanager.MountInfo{
Source: m.Source,
Destination: m.Destination,
Options: strings.Join(m.Options, ","),
})
}
// Check for unshare_groups label
if v, ok := inspect.Config.Labels["distrobox.unshare_groups"]; ok && v == "1" {