mirror of
https://github.com/89luca89/distrobox.git
synced 2026-08-17 16:34:42 -05:00
feat(migrate): add distrobox migrate subcommand for v1→v2 containers
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user