Files
distrobox/pkg/commands/create.go
T

324 lines
10 KiB
Go

package commands
import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/89luca89/distrobox/pkg/config"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
const (
maxHostnameLength = 64
)
var ErrHostnameTooLong = fmt.Errorf("hostname too long, must be less than %d characters", maxHostnameLength)
var ErrImagePullAbortedByUser = errors.New("image pull operation aborted by user")
type ContainerAlreadyExistsError struct {
ContainerName string
}
func (e *ContainerAlreadyExistsError) Error() string {
return fmt.Sprintf("container named '%s' already exists", e.ContainerName)
}
type CreateCommand struct {
cfg *config.Values
containerManager containermanager.ContainerManager
generateEntryCmd *GenerateEntryCommand
progress *ui.Progress
prompter *ui.Prompter
}
type CreateOptions struct {
// ContainerClone name of the distrobox container to use as base for a new container
ContainerClone string
// ContainerImage image to use for the container
ContainerImage string
// ContainerName name of the distrobox
ContainerName string
// ContainerHostname hostname to set inside the container
ContainerHostname string
// ContainerPlatform platform to use for the container (e.g., linux/amd64, linux/arm64)
ContainerPlatform string
Nopasswd bool
// UnshareNetNs if true, do not share host network namespace
UnshareNetNs bool
// UnshareDevsys if true, do not share host devices and sysfs dirs from host
UnshareDevsys bool
// UnshareGroups if true, do not forward user's additional groups into the container
UnshareGroups bool
// UnshareIpc if true, do not share host IPC namespace
UnshareIpc bool
// UnshareProcess if true, do not share host process namespace
UnshareProcess bool
AdditionalFlags []string
AdditionalVolumes []string
AdditionalPackages []string
ContainerPreInitHook string
ContainerInitHook string
ContainerUserCustomHome string
ContainerHomePrefix string
Init bool
Nvidia bool
DryRun bool
GenerateEntry bool
Rootful bool
ContainerAlwaysPull bool
NonInteractive bool
}
type CreateResult struct {
ContainerName string
ContainerImage string
ContainerHostname string
}
func NewCreateCommand(cfg *config.Values, cm containermanager.ContainerManager, progress *ui.Progress, prompter *ui.Prompter) *CreateCommand {
return &CreateCommand{
cfg: cfg,
containerManager: cm,
generateEntryCmd: NewGenerateEntryCommand(cfg, NewListCommand(cfg, cm)),
progress: progress,
prompter: prompter,
}
}
func (c *CreateCommand) Execute(ctx context.Context, opts CreateOptions) (*CreateResult, error) {
containerImage := c.makeContainerImage(&opts)
containerName := c.makeContainerName(&opts, containerImage)
containerHostname, err := c.makeContainerHostname(&opts)
if err != nil {
return nil, err
}
containerUserCustomHome := c.makeContainerUserCustomHome(&opts, containerName)
if !opts.DryRun && c.containerManager.Exists(ctx, containerName) {
return nil, &ContainerAlreadyExistsError{ContainerName: containerName}
}
if opts.ContainerClone != "" && !opts.DryRun {
cloneImage, err := c.clone(ctx, opts.ContainerClone)
if err != nil {
return nil, fmt.Errorf("failed to clone container %s: %w", opts.ContainerClone, err)
}
containerImage = cloneImage
}
if err := c.askPullImage(ctx, containerImage, opts); err != nil {
return nil, err
}
c.progress.Next("Creating '%s' using image %s", containerName, containerImage)
err = c.containerManager.Create(
ctx,
containermanager.CreateOptions{
ContainerName: containerName,
ContainerImage: containerImage,
ContainerClone: opts.ContainerClone,
ContainerUserCustomHome: containerUserCustomHome,
ContainerHostname: containerHostname,
ContainerPlatform: opts.ContainerPlatform,
Nopasswd: opts.Nopasswd,
UnshareDevsys: opts.UnshareDevsys,
UnshareGroups: opts.UnshareGroups,
UnshareIPC: opts.UnshareIpc,
UnshareNetNS: opts.UnshareNetNs,
UnshareProcess: opts.UnshareProcess,
AdditionalFlags: splitFields(opts.AdditionalFlags),
AdditionalVolumes: splitFields(opts.AdditionalVolumes),
AdditionalPackages: opts.AdditionalPackages,
ContainerPreInitHook: opts.ContainerPreInitHook,
ContainerInitHook: opts.ContainerInitHook,
Init: opts.Init,
Nvidia: opts.Nvidia,
DryRun: opts.DryRun,
},
)
if err != nil {
c.progress.Fail()
return nil, fmt.Errorf("failed to create container: %w", err)
}
c.progress.Done()
if opts.GenerateEntry && !opts.DryRun && !opts.Rootful {
err := c.generateEntryCmd.Execute(
ctx,
&GenerateEntryOptions{
ContainerName: containerName,
// "auto" triggers distro icon detection; without it the entry
// would get an empty Icon=.
Icon: "auto",
},
)
if err != nil {
return nil, fmt.Errorf("failed to generate entry for container %s: %w", containerName, err)
}
}
return &CreateResult{
ContainerName: containerName,
ContainerImage: containerImage,
ContainerHostname: containerHostname,
}, nil
}
// Determine right containerImage to use
//
// If no clone option and no container image, let's choose a default image to use.
//
// If no name is specified and we're using the default container_image, then let's
// set a default name for the container, that is distinguishable from the default
// toolbx one. This will avoid problems when using both toolbx and distrobox on
// the same system.
func (c *CreateCommand) makeContainerImage(opts *CreateOptions) string {
containerImage := opts.ContainerImage
if opts.ContainerClone == "" && containerImage == "" {
containerImage = c.cfg.DefaultContainerImage
}
if opts.DryRun && opts.ContainerClone != "" {
containerImage = opts.ContainerClone
}
return containerImage
}
// Determine right containerName to use
//
// If no name is specified and no image is specified, then let's
// set a default name for the container, that is distinguishable from the default
// toolbx one. This will avoid problems when using both toolbx and distrobox on
// the same system.
//
// If no container_name is declared, we build our container name starting from the
// container image specified.
//
// Examples:
//
// alpine -> alpine
// ubuntu:20.04 -> ubuntu-20.04
// registry.fedoraproject.org/fedora-toolbox:39 -> fedora-toolbox-39
// ghcr.io/void-linux/void-linux:latest-full-x86_64 -> void-linux-latest-full-x86_64
func (c *CreateCommand) makeContainerName(opts *CreateOptions, containerImage string) string {
containerName := opts.ContainerName
// Mirror the shell: when no name is given and the effective image is the
// default image, use the default name (distinguishable from toolbx).
if containerName == "" && opts.ContainerClone == "" && containerImage == c.cfg.DefaultContainerImage {
containerName = c.cfg.DefaultContainerName
}
if containerName == "" {
base := path.Base(containerImage)
base = strings.ReplaceAll(base, ":", "-")
base = strings.ReplaceAll(base, ".", "-")
containerName = base
}
return containerName
}
func (c *CreateCommand) makeContainerHostname(opts *CreateOptions) (string, error) {
containerHostname := opts.ContainerHostname
if containerHostname == "" {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("unable to get hostname: %w", err)
}
containerHostname = hostname
if opts.UnshareNetNs {
containerHostname = fmt.Sprintf("%s.%s", opts.ContainerName, hostname)
}
}
if len(containerHostname) > maxHostnameLength {
return "", ErrHostnameTooLong
}
return containerHostname, nil
}
// Determine right containerUserCustomHome to use
//
// We check if the user has a custom home prefix to use for the container home.
// If we have a home prefix to use, and no custom home set, then we set
// the custom home to be PREFIX/CONTAINER_NAME
func (c *CreateCommand) makeContainerUserCustomHome(
opts *CreateOptions,
containerName string,
) string {
containerUserCustomHome := strings.TrimRight(opts.ContainerUserCustomHome, "/")
if opts.ContainerHomePrefix != "" && containerUserCustomHome == "" {
containerUserCustomHome = filepath.Join(opts.ContainerHomePrefix, containerName)
}
return containerUserCustomHome
}
func (c *CreateCommand) clone(ctx context.Context, containerName string) (string, error) {
i, err := c.containerManager.InspectContainer(ctx, containerName)
if err != nil {
return "", fmt.Errorf("failed to inspect container status: %w", err)
}
if i.ContainerStatus == "running" {
return "", errors.New("cannot clone running container, name: " + containerName)
}
commitTag := fmt.Sprintf("%s:%s", strings.ToLower(containerName), time.Now().Format("2006-01-02"))
err = c.containerManager.Commit(ctx, i.ContainerID, commitTag)
if err != nil {
return "", fmt.Errorf("failed to commit container '%s:%s': %w", i.ContainerID, commitTag, err)
}
return commitTag, nil
}
// splitFields word-splits each entry of in on whitespace. A single CLI
// invocation like --additional-flags "--label=a=1 --label=b=2" arrives here as
// one slice element with embedded spaces; podman would parse that as a single
// flag value. Splitting matches the manifest parser's behavior.
func splitFields(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
out = append(out, strings.Fields(v)...)
}
return out
}
func (c *CreateCommand) askPullImage(ctx context.Context, containerImage string, opts CreateOptions) error {
if opts.ContainerAlwaysPull || !c.containerManager.ImageExists(ctx, containerImage) {
skipConfirm := opts.NonInteractive || opts.ContainerAlwaysPull || opts.DryRun
if !skipConfirm {
msg := fmt.Sprintf("Image '%s' not found.\n. Do you want to pull the image now?", containerImage)
answer := c.prompter.Prompt(msg, true)
if !answer {
return ErrImagePullAbortedByUser
}
}
err := c.containerManager.PullImage(ctx, containerImage, opts.ContainerPlatform, opts.DryRun)
if err != nil {
return fmt.Errorf("failed to pull image '%s': %w", containerImage, err)
}
}
return nil
}