diff --git a/cli/command/app/client_test.go b/cli/command/app/client_test.go new file mode 100644 index 000000000000..590341f4f28c --- /dev/null +++ b/cli/command/app/client_test.go @@ -0,0 +1,9 @@ +package app + +import ( + "github.com/docker/docker/client" +) + +type fakeClient struct { + client.Client +} diff --git a/cli/command/app/cmd.go b/cli/command/app/cmd.go new file mode 100644 index 000000000000..6353b761bc08 --- /dev/null +++ b/cli/command/app/cmd.go @@ -0,0 +1,77 @@ +package app + +import ( + "context" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/container" + "github.com/docker/cli/cli/command/image" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// cliAdapter adds convenience methods to the command.Cli interface +// for building images, running containers, and copying files from containers +type cliAdapter interface { + command.Cli + // + RunBuild(context.Context, *image.BuildOptions) error + RunRun(context.Context, *pflag.FlagSet, *container.RunOptions, *container.ContainerOptions) error + RunCopy(context.Context, *container.CopyOptions) error +} + +type dockerCliAdapter struct { + command.Cli +} + +func newDockerCliAdapter(c command.Cli) cliAdapter { + return &dockerCliAdapter{ + c, + } +} + +func (r *dockerCliAdapter) RunBuild(ctx context.Context, buildOpts *image.BuildOptions) error { + return image.RunBuild(ctx, r, buildOpts) +} + +func (r *dockerCliAdapter) RunRun(ctx context.Context, flags *pflag.FlagSet, runOpts *container.RunOptions, containerOpts *container.ContainerOptions) error { + return container.RunRun(ctx, r, flags, runOpts, containerOpts) +} + +func (r *dockerCliAdapter) RunCopy(ctx context.Context, copyOpts *container.CopyOptions) error { + return container.RunCopy(ctx, r, copyOpts) +} + +// NewAppCommand returns a cobra command for `app` subcommands +func NewAppCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "app", + Short: "Manage application with Docker", + Args: cli.NoArgs, + RunE: command.ShowHelp(dockerCli.Err()), + } + cmd.AddCommand( + NewInstallCommand(dockerCli), + NewLaunchCommand(dockerCli), + NewRemoveCommand(dockerCli), + ) + return cmd +} + +func markFlagsHiddenExcept(cmd *cobra.Command, unhidden ...string) { + contains := func(n string) bool { + for _, v := range unhidden { + if v == n { + return true + } + } + return false + } + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + name := flag.Name + if !contains(name) { + flag.Hidden = true + } + }) +} diff --git a/cli/command/app/install.go b/cli/command/app/install.go new file mode 100644 index 000000000000..4847edf02377 --- /dev/null +++ b/cli/command/app/install.go @@ -0,0 +1,445 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/container" + "github.com/docker/cli/cli/command/image" + "github.com/docker/docker/errdefs" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// NewInstallCommand creates a new `docker app install` command +func NewInstallCommand(dockerCli command.Cli) *cobra.Command { + var options *AppOptions + + cmd := &cobra.Command{ + Use: "install [OPTIONS] URL [COMMAND] [ARG...]", + Short: "Install app from URL", + Args: cli.RequiresMinArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + options.setArgs(args) + adapter := newDockerCliAdapter(dockerCli) + return installApp(cmd.Context(), adapter, cmd.Flags(), options) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterDirs + }, + } + + flags := cmd.Flags() + flags.SetInterspersed(false) + + options = addInstallFlags(flags, defaultAppBase(), dockerCli.ContentTrustEnabled()) + + return cmd +} + +func addInstallFlags(flags *pflag.FlagSet, dest string, trust bool) *AppOptions { + options := newAppOptions() + + bflags := pflag.NewFlagSet("build", pflag.ContinueOnError) + rflags := pflag.NewFlagSet("run", pflag.ContinueOnError) + eflags := pflag.NewFlagSet("copy", pflag.ContinueOnError) + + id := time.Now().UnixNano() + imageIDFile := filepath.Join(os.TempDir(), fmt.Sprintf("docker-app-%d.iid", id)) + containerIDFile := filepath.Join(os.TempDir(), fmt.Sprintf("docker-app-%d.cid", id)) + + // install supported flags + flags.StringVar(&options.egress, "egress", "/egress", "Set container path to export") + flags.StringVar(&options.destination, "destination", dest, "Set local host path for app") + flags.BoolVar(&options.launch, "launch", false, "Start app after installation") + flags.BoolVarP(&options.detach, "detach", "d", false, "Do not wait for app to finish") + flags.BoolVar(&options.force, "force", false, "Force install even if the app exists") + flags.StringVar(&options.name, "name", "", "App name") + + // build/run flags + flags.StringVar(&options.imageIDFile, "iidfile", imageIDFile, "Write the image ID to the file") + flags.StringVar(&options.containerIDFile, "cidfile", containerIDFile, "Write the container ID to the file") + + flags.Lookup("iidfile").DefValue = "auto" + flags.Lookup("cidfile").DefValue = "auto" + + options.buildOpts = image.AddBuildFlags(bflags, trust) + options.runOpts = container.AddRunFlags(rflags, trust) + options.containerOpts = container.AddFlags(rflags) + options.copyOpts = container.AddCopyFlags(eflags) + + match := func(n string, names []string) bool { + for _, name := range names { + if n == name { + return true + } + } + return false + } + include := func(flags, fs *pflag.FlagSet, names []string) { + fs.VisitAll(func(flag *pflag.Flag) { + if match(flag.Name, names) { + flags.AddFlag(flag) + } + }) + } + exclude := func(flags, fs *pflag.FlagSet, names []string) { + fs.VisitAll(func(flag *pflag.Flag) { + if !match(flag.Name, names) { + flags.AddFlag(flag) + } + }) + } + + // `docker build` flags + // all build flags are supported as is except for "iidfile" + // install intercepts the iidfile flag + exclude(flags, bflags, []string{"iidfile"}) + // `docker run` flags + // we could add more if necessary except for the following + // that are conflicting data types or duplicates of the build flags: + // "platform", "pull", "rm", "quiet", + // "add-host", "cgroup-parent", "cpu-period", "cpu-quota", "cpu-shares", "cpuset-cpus", "cpuset-mems", + // "disable-content-trust", "isolation", "label", "memory", "memory-swap", + // "network", "security-opt", "shm-size", "tty", "ulimit" + include(flags, rflags, []string{"entrypoint", "env", "env-file", "privileged", "volume", "workdir"}) + // `docker cp` flags + include(flags, eflags, []string{"archive", "follow-link"}) + + return options +} + +func setBuildArgs(options *AppOptions) error { + bopts := options.buildOpts + if bopts == nil { + return errors.New("build options not set") + } + + set := func(n, v string) { + bopts.SetBuildArg(n + "=" + v) + } + + set("DOCKER_APP_BASE", options._appBase) + appPath, err := options.appPath() + if err != nil { + return err + } + set("DOCKER_APP_PATH", appPath) + + set("HOSTOS", runtime.GOOS) + set("HOSTARCH", runtime.GOARCH) + + version := options.buildVersion() + if version != "" { + set("VERSION", version) + } + + // user info + u, err := user.Current() + if err != nil { + return err + } + set("USERNAME", u.Username) + set("USERHOME", u.HomeDir) + set("USERID", u.Uid) + set("USERGID", u.Gid) + + return nil +} + +func installApp(ctx context.Context, adapter cliAdapter, flags *pflag.FlagSet, options *AppOptions) error { + if err := validateAppOptions(options); err != nil { + return err + } + + dir, err := runInstall(ctx, adapter, flags, options) + if err != nil { + return err + } + + bin, err := runPostInstall(ctx, adapter, dir, options) + if err != nil { + return err + } + + // if launch is true, run the app + // only for single file or multi file with the run file + if options.launch && bin != "" { + return launch(bin, options) + } + return nil +} + +func setDefaultEnv() { + if os.Getenv("DOCKER_BUILDKIT") == "" { + os.Setenv("DOCKER_BUILDKIT", "1") + } + + platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) + if os.Getenv("DOCKER_DEFAULT_PLATFORM") == "" { + os.Setenv("DOCKER_DEFAULT_PLATFORM", platform) + } +} + +// runInstall calls the build, run, and cp commands +func runInstall(ctx context.Context, dockerCli cliAdapter, flags *pflag.FlagSet, options *AppOptions) (string, error) { + setDefaultEnv() + + if err := setBuildArgs(options); err != nil { + return "", err + } + + iid, err := buildImage(ctx, dockerCli, options) + if err != nil { + return "", err + } + fmt.Fprintf(dockerCli.Out(), "Image ID: %s\n", iid) + + cid, err := runContainer(ctx, dockerCli, iid, flags, options) + if err != nil { + return "", err + } + fmt.Fprintf(dockerCli.Out(), "Container ID: %s\n", cid) + + dest, err := copyFiles(ctx, dockerCli, cid, options) + if err != nil { + return "", err + } + fmt.Fprintf(dockerCli.Out(), "App copied to %s\n", dest) + + return dest, nil +} + +func buildImage(ctx context.Context, dockerCli cliAdapter, options *AppOptions) (string, error) { + bopts := options.buildOpts + bopts.SetContext(options.buildContext()) + bopts.SetImageIDFile(options.imageIDFile) + if err := dockerCli.RunBuild(ctx, bopts); err != nil { + return "", err + } + + iid, err := options.imageID() + if err != nil { + return "", err + } + + return iid, nil +} + +func runContainer(ctx context.Context, dockerCli cliAdapter, iid string, flags *pflag.FlagSet, options *AppOptions) (string, error) { + ropts := options.runOpts + copts := options.containerOpts + copts.Image = iid + copts.Args = options.runArgs() + copts.SetContainerIDFile(options.containerIDFile) + if err := dockerCli.RunRun(ctx, flags, ropts, copts); err != nil { + return "", err + } + + cid, err := options.containerID() + if err != nil { + return "", err + } + + return cid, nil +} + +func copyFiles(ctx context.Context, dockerCli cliAdapter, cid string, options *AppOptions) (string, error) { + dir, err := options.cacheDir() + if err != nil { + return "", err + } + + eopts := options.copyOpts + eopts.SetDestination(dir) + eopts.SetSource(fmt.Sprintf("%s:%s", cid, options.egress)) + if err := dockerCli.RunCopy(ctx, eopts); err != nil { + return "", err + } + return filepath.Join(dir, filepath.Base(options.egress)), nil +} + +const appExistWarn = `WARNING! This will replace the existing app. +Are you sure you want to continue?` + +func runPostInstall(ctx context.Context, dockerCli cliAdapter, dir string, options *AppOptions) (string, error) { + if !options.isDockerAppBase() { + return "", installCustom(dir, options.destination, options) + } + + binPath := options.binPath() + if err := os.MkdirAll(binPath, 0o755); err != nil { + return "", err + } + + appPath, err := options.appPath() + if err != nil { + return "", err + } + + if fileExist(appPath) { + if !options.force { + r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), appExistWarn) + if err != nil { + return "", err + } + if !r { + return "", errdefs.Cancelled(errors.New("app install has been canceled")) + } + } + if err := removeApp(dockerCli, binPath, appPath, options); err != nil { + return "", err + } + } + + // for the default destination + // if there is only one file, create symlink for the file + if fp, err := oneChild(dir); err == nil && fp != "" { + appName := options.name + if appName == "" { + appName = makeAppName(fp) + } + if err := validateName(appName); err != nil { + return "", err + } + + link, err := installOne(appName, dir, fp, binPath, appPath) + if err != nil { + return "", err + } + fmt.Fprintf(dockerCli.Out(), "App installed: %s\n", link) + return link, nil + } + + // if there is a run file, create symlink for the run file. + if fp, err := locateFile(dir, runnerName); err == nil && fp != "" { + appName := options.name + if appName == "" { + appName = makeAppName(appPath) + } + if err := validateName(appName); err != nil { + return "", err + } + + link, err := installRunFile(appName, dir, fp, binPath, appPath) + if err != nil { + return "", err + } + fmt.Fprintf(dockerCli.Out(), "App installed: %s\n", link) + return link, nil + } + + // custom install + if err := installCustom(dir, appPath, options); err != nil { + return "", err + } + + fmt.Fprintf(dockerCli.Out(), "App installer ran successfully\n") + return "", nil +} + +// removeApp removes the existing app +func removeApp(dockerCli cliAdapter, binPath, appPath string, options *AppOptions) error { + envs, _ := options.makeEnvs() + runUninstaller(dockerCli, appPath, envs) + + if err := os.RemoveAll(appPath); err != nil { + return err + } + targets, err := findSymlinks(binPath) + if err != nil { + return err + } + cleanupSymlink(dockerCli, appPath, targets) + return nil +} + +// installOne creates a symlink to the only file in appPath +func installOne(appName, egress, runPath, binPath, appPath string) (string, error) { + link := filepath.Join(binPath, appName) + target := filepath.Join(appPath, appName) + return install(link, target, egress, appPath) +} + +// installRunFile creates a symlink to the run file in appPath +func installRunFile(appName, egress, runPath, binPath, appPath string) (string, error) { + link := filepath.Join(binPath, appName) + target := filepath.Join(appPath, filepath.Base(runPath)) + return install(link, target, egress, appPath) +} + +// instal creates a symlink to the target file +func install(link, target, egress, appPath string) (string, error) { + if ok, err := isSymlinkOK(link, target); err != nil { + return "", err + } else { + if !ok { + return "", fmt.Errorf("another app/version file exists: %s", link) + } + if err := os.Remove(link); err != nil { + if !os.IsNotExist(err) { + return "", err + } + } + } + + if err := os.MkdirAll(filepath.Dir(appPath), 0o755); err != nil { + return "", err + } + if err := os.Rename(egress, appPath); err != nil { + return "", err + } + + // make target executable + if err := os.Chmod(target, 0o755); err != nil { + return "", err + } + + if err := os.Symlink(target, link); err != nil { + return "", err + } + return link, nil +} + +func installCustom(dir string, appPath string, options *AppOptions) error { + if err := os.MkdirAll(filepath.Dir(appPath), 0o755); err != nil { + return err + } + if err := os.Rename(dir, appPath); err != nil { + return err + } + + // optionally run the installer if it exists + installer := filepath.Join(appPath, installerName) + if !fileExist(installer) { + return nil + } + if err := os.Chmod(installer, 0o755); err != nil { + return err + } + + return launch(installer, options) +} + +func fileExist(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// makeAppName derives the app name from the base name of the path +// after removing the version and extension +func makeAppName(path string) string { + n := filepath.Base(path) + n = strings.SplitN(n, "@", 2)[0] + n = strings.SplitN(n, ".", 2)[0] + return n +} diff --git a/cli/command/app/install_test.go b/cli/command/app/install_test.go new file mode 100644 index 000000000000..7b671378c28d --- /dev/null +++ b/cli/command/app/install_test.go @@ -0,0 +1,262 @@ +package app + +import ( + "io" + "testing" + + "github.com/docker/cli/internal/test" + "github.com/docker/cli/opts" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "gotest.tools/v3/assert" +) + +func TestNewInstallCommandInvalidArgs(t *testing.T) { + tests := []struct { + name string + args []string + expected string + }{ + { + name: "empty args - no url", + args: []string{}, + expected: "requires at least 1 argument", + }, + { + name: "some args - no url", + args: []string{"-q", "--build-arg", "var=val", "--env", "var=val"}, + expected: "requires at least 1 argument", + }, + { + name: "unsupported flag", + args: []string{"--random-option", "url"}, + expected: "unknown flag: --random-option", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{}) + cmd := NewInstallCommand(cli) + cmd.SetOut(io.Discard) + cmd.SetArgs(tc.args) + err := cmd.Execute() + assert.ErrorContains(t, err, tc.expected) + }) + } +} + +func TestNewInstallCommandAddInstallFlags(t *testing.T) { + assertChange := func(cmd *cobra.Command, n string, v any) { + f := cmd.Flag(n) + assert.Equal(t, f.Changed, true) + if f.Value.Type() == "list" { + s := f.Value.(*opts.ListOpts).GetAll() + assert.DeepEqual(t, s, v) + } else { + assert.Equal(t, f.Value.String(), v) + } + } + assertNoChange := func(cmd *cobra.Command, n string, v any) { + f := cmd.Flag(n) + assert.Equal(t, f.Changed, false) + if f.Value.Type() == "list" { + s := f.Value.(*opts.ListOpts).GetAll() + assert.DeepEqual(t, s, v) + } else { + assert.Equal(t, f.Value.String(), v) + } + } + assertNoChangeNotEmpty := func(cmd *cobra.Command, n string) { + f := cmd.Flag(n) + assert.Equal(t, f.Changed, false) + assert.Assert(t, f.Value.String() != "") + } + + tests := []struct { + name string + args []string + runE func(*cobra.Command, []string) error + }{ + { + name: "all install options", + args: []string{"--cidfile", "cid.txt", "--destination", "/a/b/c", "--detach", "--egress", "/d/e/f", "--iidfile", "id.txt", "--launch", "url"}, + runE: func(cmd *cobra.Command, args []string) error { + assert.Assert(t, len(args) == 1) + assert.Equal(t, args[0], "url") + + assertChange(cmd, "cidfile", "cid.txt") + assertChange(cmd, "destination", "/a/b/c") + assertChange(cmd, "detach", "true") + assertChange(cmd, "egress", "/d/e/f") + assertChange(cmd, "iidfile", "id.txt") + assertChange(cmd, "launch", "true") + return nil + }, + }, + { + name: "default install options", + args: []string{"url"}, + runE: func(cmd *cobra.Command, args []string) error { + assert.Assert(t, len(args) == 1) + assert.Equal(t, args[0], "url") + + assertNoChangeNotEmpty(cmd, "cidfile") + assertNoChange(cmd, "destination", defaultAppBase()) + assertNoChange(cmd, "detach", "false") + assertNoChange(cmd, "egress", "/egress") + assertNoChangeNotEmpty(cmd, "iidfile") + assertNoChange(cmd, "launch", "false") + return nil + }, + }, + { + name: "common build options", + args: []string{"--build-arg", "var=val", "--file", "docker.file", "--platform", "target_os/arch", "--pull=false", "--tag", "name:tag", "url"}, + runE: func(cmd *cobra.Command, args []string) error { + assert.Equal(t, len(args), 1) + assert.Equal(t, args[0], "url") + + // assertChange(cmd, "build-arg", []string{"var=val"}) + assertChange(cmd, "file", "docker.file") + assertChange(cmd, "platform", "target_os/arch") + assertChange(cmd, "pull", "false") + assertChange(cmd, "tag", []string{"name:tag"}) + return nil + }, + }, + { + name: "supported run options", + args: []string{"--entrypoint", "/entry.sh", "--env", "var=val", "--env-file", ".env", "--privileged", "--volume", "$HOME:/home", "--workdir", "/tmp", "url"}, + runE: func(cmd *cobra.Command, args []string) error { + assert.Equal(t, len(args), 1) + assert.Equal(t, args[0], "url") + + assertChange(cmd, "entrypoint", "/entry.sh") + assertChange(cmd, "env", []string{"var=val"}) + assertChange(cmd, "env-file", []string{".env"}) + assertChange(cmd, "privileged", "true") + assertChange(cmd, "volume", []string{"$HOME:/home"}) + assertChange(cmd, "workdir", "/tmp") + return nil + }, + }, + { + name: "container run command args", + args: []string{"url", "cmd", "--arg1", "--arg2"}, + runE: func(cmd *cobra.Command, args []string) error { + assert.Equal(t, len(args), 4) + assert.Equal(t, args[0], "url") + + assert.Equal(t, args[1], "cmd") + assert.Equal(t, args[2], "--arg1") + assert.Equal(t, args[3], "--arg2") + return nil + }, + }, + { + name: "supported cp options", + args: []string{"--archive", "--follow-link", "url"}, + runE: func(cmd *cobra.Command, args []string) error { + assert.Equal(t, len(args), 1) + assert.Equal(t, args[0], "url") + + assertChange(cmd, "archive", "true") + assertChange(cmd, "follow-link", "true") + return nil + }, + }, + { + name: "launch args after option terminator --", + args: []string{"url", "--", "launch-sub-cmd", "--arg1", "--arg2"}, + runE: func(cmd *cobra.Command, args []string) error { + assert.Equal(t, len(args), 5) + assert.Equal(t, args[0], "url") + + assert.Equal(t, args[1], "--") + + assert.Equal(t, args[2], "launch-sub-cmd") + assert.Equal(t, args[3], "--arg1") + assert.Equal(t, args[4], "--arg2") + return nil + }, + }, + { + name: "split run/launch args by option terminator --", + args: []string{"--entrypoint", "/entry.sh", "url", "cmd", "arg1", "--", "launch-sub-cmd", "--arg2"}, + runE: func(cmd *cobra.Command, args []string) error { + assert.Equal(t, len(args), 6) + assert.Equal(t, args[0], "url") + + options := AppOptions{} + options.setArgs(args) + assertChange(cmd, "entrypoint", "/entry.sh") + assert.Equal(t, options.buildContext(), "url") + assert.DeepEqual(t, options.runArgs(), []string{"cmd", "arg1"}) + assert.DeepEqual(t, options.launchArgs(), []string{"launch-sub-cmd", "--arg2"}) + return nil + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{}) + cmd := NewInstallCommand(cli) + cmd.SetOut(io.Discard) + cmd.SetArgs(tc.args) + cmd.RunE = tc.runE + cmd.Execute() + }) + } +} + +func TestAddInstallFlagsAppOptions(t *testing.T) { + tests := []struct { + name string + args []string + check func(*AppOptions) + }{ + { + name: "all install options", + args: []string{"--destination", "/a/b/c", "--egress", "/d/e/f", "--iidfile", "id.txt", "--cidfile", "cid.txt", "--detach", "--launch", "-"}, + check: func(o *AppOptions) { + assert.Equal(t, o.destination, "/a/b/c") + assert.Equal(t, o.egress, "/d/e/f") + assert.Equal(t, o.imageIDFile, "id.txt") + assert.Equal(t, o.containerIDFile, "cid.txt") + assert.Equal(t, o.detach, true) + assert.Equal(t, o.launch, true) + }, + }, + { + name: "default install options", + args: []string{"-"}, + check: func(o *AppOptions) { + assert.Equal(t, o.destination, defaultAppBase()) + assert.Equal(t, o.egress, "/egress") + assert.Assert(t, o.imageIDFile != "") + assert.Assert(t, o.containerIDFile != "") + assert.Equal(t, o.detach, false) + assert.Equal(t, o.launch, false) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + flags := &pflag.FlagSet{} + + options := addInstallFlags(flags, defaultAppBase(), false) + + flags.Parse(tc.args) + + assert.Assert(t, options != nil) + assert.Assert(t, options.buildOpts != nil) + assert.Assert(t, options.runOpts != nil) + assert.Assert(t, options.containerOpts != nil) + assert.Assert(t, options.copyOpts != nil) + assert.Assert(t, options._appBase == defaultAppBase()) + + tc.check(options) + }) + } +} diff --git a/cli/command/app/launch.go b/cli/command/app/launch.go new file mode 100644 index 000000000000..0359ee59a553 --- /dev/null +++ b/cli/command/app/launch.go @@ -0,0 +1,91 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// NewLaunchCommand creates a new cobra.Command for `docker app launch` +func NewLaunchCommand(dockerCli command.Cli) *cobra.Command { + var options *AppOptions + + cmd := &cobra.Command{ + Use: "launch [OPTIONS] URL [COMMAND] [ARG...]", + Short: "Launch app from URL", + Args: cli.RequiresMinArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + options.setArgs(args) + adapter := newDockerCliAdapter(dockerCli) + return runApp(cmd.Context(), adapter, cmd.Flags(), options) + }, + } + + flags := cmd.Flags() + flags.SetInterspersed(false) + + id := time.Now().UnixNano() + dest := filepath.Join(os.TempDir(), fmt.Sprintf("docker-app-launch-%d", id)) + + options = addInstallFlags(flags, dest, dockerCli.ContentTrustEnabled()) + flags.Lookup("destination").DefValue = "auto" + + flags.MarkHidden("launch") + // and more + markFlagsHiddenExcept(cmd, []string{"destination", "detach", "quiet"}...) + + return cmd +} + +func runApp(ctx context.Context, adapter cliAdapter, flags *pflag.FlagSet, options *AppOptions) error { + if err := validateAppOptions(options); err != nil { + return err + } + + dir, err := runInstall(ctx, adapter, flags, options) + if err != nil { + return err + } + + return runLaunch(dir, options) +} + +func runLaunch(dir string, options *AppOptions) error { + locate := func() (string, error) { + if fp, err := oneChild(dir); err == nil && fp != "" { + return fp, nil + } + appName := options.name + if appName == "" { + appName = runnerName + } + if fp, err := locateFile(dir, appName); err == nil && fp != "" { + return fp, nil + } + return "", errors.New("no app file found") + } + + fp, err := locate() + if err != nil { + return err + } + + return launch(fp, options) +} + +// launch copies the current environment and set DOCKER_APP_BASE before spawning the app +func launch(app string, options *AppOptions) error { + envs, err := options.makeEnvs() + if err != nil { + return err + } + return spawn(app, options.launchArgs(), envs, options.detach) +} diff --git a/cli/command/app/opts.go b/cli/command/app/opts.go new file mode 100644 index 000000000000..56eb85ccc186 --- /dev/null +++ b/cli/command/app/opts.go @@ -0,0 +1,307 @@ +package app + +import ( + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/docker/cli/cli/command/container" + "github.com/docker/cli/cli/command/image" +) + +// runnerName is the default executable app name for starting the app +const runnerName = "run" + +// installerName is the executable name for custom installation +const installerName = "install" + +// uninstallerName is the executable name for custom installation +const uninstallerName = "uninstall" + +// namePattern is for validating app name +const namePattern = "^[a-zA-Z0-9][a-zA-Z0-9_.+-]+$" + +var nameRegexp = regexp.MustCompile(namePattern) + +func validateName(s string) error { + if !nameRegexp.MatchString(s) { + return fmt.Errorf("name %q is invalid, regexp: %q", s, namePattern) + } + return nil +} + +// semverPattern is for splitting semver from a context path/URL +const semverPattern = `@v?\d+(\.\d+)?(\.\d+)?$` + +var semverRegexp = regexp.MustCompile(semverPattern) + +func splitSemver(s string) (string, string) { + if semverRegexp.MatchString(s) { + idx := strings.LastIndex(s, "@") + // unlikely otherwise ignore + if idx == -1 { + return s, "" + } + v := s[idx+1:] + if v[0] == 'v' { + v = v[1:] + } + return s[:idx], v + } + return s, "" +} + +// defaultAppBase is docker app's base location specified by +// DOCKER_APP_BASE environment variable defaulted to ~/.docker/app/ +func defaultAppBase() string { + if base := os.Getenv("DOCKER_APP_BASE"); base != "" { + return filepath.Clean(base) + } + + // locate .docker/app starting from the current working directory + // for supporting apps on a per project basis + wd, err := os.Getwd() + if err == nil { + if dir, err := locateDir(wd, ".docker"); err == nil { + return filepath.Join(dir, "app") + } + } + + // default ~/.docker/app + // ignore error and use the current working directory + // if home directory is not available + home, _ := os.UserHomeDir() + return filepath.Join(home, ".docker", "app") +} + +type commonOptions struct { + // command line args + _args []string + + // docker app base location, fixed once set + _appBase string +} + +func (o *commonOptions) setArgs(args []string) { + o._args = args +} + +// buildContext returns the build context for building image +func (o *commonOptions) buildContext() string { + if len(o._args) == 0 { + return "." + } + c, _ := splitSemver(o._args[0]) + return c +} + +func (o *commonOptions) buildVersion() string { + if len(o._args) == 0 { + return "" + } + _, v := splitSemver(o._args[0]) + return v +} + +// appPath returns the app directory under the default app base +func (o *commonOptions) appPath() (string, error) { + if len(o._args) == 0 { + return "", errors.New("missing args") + } + return o.makeAppPath(o._args[0]) +} + +// binPath returns the bin directory under the default app base +func (o *commonOptions) binPath() string { + return filepath.Join(o._appBase, "bin") +} + +// pkgPath returns the pkg directory under the default app base +func (o *commonOptions) pkgPath() string { + return filepath.Join(o._appBase, "pkg") +} + +// makeAppPath builds the default app path +// in the format: appBase/pkg/scheme/host/path +func (o *commonOptions) makeAppPath(s string) (string, error) { + u, err := parseURL(s) + if err != nil { + return "", err + } + if u.Path == "" { + return "", fmt.Errorf("missing path: %v", u) + } + p := filepath.Join(o._appBase, "pkg", u.Scheme, u.Host, shortenPath(u.Path)) + if u.Fragment == "" { + return p, nil + } + return fmt.Sprintf("%s#%s", p, u.Fragment), nil +} + +func (o *commonOptions) makeEnvs() (map[string]string, error) { + envs := make(map[string]string) + + // copy the current environment + for _, v := range os.Environ() { + kv := strings.SplitN(v, "=", 2) + envs[kv[0]] = kv[1] + } + + envs["DOCKER_APP_BASE"] = o._appBase + appPath, err := o.appPath() + if err != nil { + return nil, err + } + envs["DOCKER_APP_PATH"] = appPath + + envs["VERSION"] = o.buildVersion() + + envs["HOSTOS"] = runtime.GOOS + envs["HOSTARCH"] = runtime.GOARCH + + // user info + u, err := user.Current() + if err != nil { + return nil, err + } + envs["USERNAME"] = u.Username + envs["USERHOME"] = u.HomeDir + envs["USERID"] = u.Uid + envs["USERGID"] = u.Gid + + return envs, nil +} + +// AppOptions holds the options for the `app` subcommands +type AppOptions struct { + commonOptions + + // flags for install + + // path on local host + destination string + + // path in container + egress string + + // exit immediately after launching the app + detach bool + + // start exported app + launch bool + + // overwrite existing app + force bool + + // app name + name string + + // the following are existing flags + + // build flags + // all `docker build` flags are supported as is by `docker install` + // iidfile is required for the run step + // auto generated if not provided + imageIDFile string + + // run flags + // only a subset of run flags are supported + // cidfile is auto generated if not provided + containerIDFile string + + // options + buildOpts *image.BuildOptions + runOpts *container.RunOptions + containerOpts *container.ContainerOptions + copyOpts *container.CopyOptions +} + +// runArgs returns the command line args for running the container +func (o *AppOptions) runArgs() []string { + if len(o._args) <= 1 { + return nil + } + cArgs, _ := splitAtDashDash(o._args[1:]) + return cArgs +} + +// launchArgs returns the command line args for launching the app +// the args after the first "--" are considered launch args +func (o *AppOptions) launchArgs() []string { + if len(o._args) <= 1 { + return nil + } + _, hArgs := splitAtDashDash(o._args[1:]) + return hArgs +} + +// isDockerAppBase returns true if the destination is under the default app base +func (o *AppOptions) isDockerAppBase() bool { + s := filepath.Clean(o.destination) + return strings.HasPrefix(s, o._appBase) +} + +// cacheDir returns a temp cache directory under the default app base +// appBase is chosen as the parent directory to avoid issues such as: +// permission, disk space, renaming across partitions. +func (o *AppOptions) cacheDir() (string, error) { + id := time.Now().UnixNano() + dir := filepath.Join(o._appBase, ".cache", strconv.FormatInt(id, 16)) + err := os.MkdirAll(dir, 0o755) + return dir, err +} + +func (o *AppOptions) imageID() (string, error) { + if id, err := os.ReadFile(o.imageIDFile); err != nil { + return "", err + } else { + // TODO investigate: -q/--quiet flag causes extra LF from the docker builder + return strings.TrimSpace(string(id)), nil + } +} + +func (o *AppOptions) containerID() (string, error) { + if id, err := os.ReadFile(o.containerIDFile); err != nil { + return "", err + } else { + return string(id), nil + } +} + +func newAppOptions() *AppOptions { + return &AppOptions{ + commonOptions: commonOptions{ + _appBase: defaultAppBase(), + }, + } +} + +func validateAppOptions(options *AppOptions) error { + if options.destination == "" { + return errors.New("destination is required") + } + if options.egress == "" { + return errors.New("egress is required") + } + + return nil +} + +type removeOptions struct { + commonOptions +} + +func newRemoveOptions() *removeOptions { + return &removeOptions{ + commonOptions: commonOptions{ + _appBase: defaultAppBase(), + }, + } +} diff --git a/cli/command/app/remove.go b/cli/command/app/remove.go new file mode 100644 index 000000000000..29764e4ac9b9 --- /dev/null +++ b/cli/command/app/remove.go @@ -0,0 +1,132 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +// NewRemoveCommand creates a new `docker app remove` command +func NewRemoveCommand(dockerCli command.Cli) *cobra.Command { + var options *removeOptions + + cmd := &cobra.Command{ + Use: "remove [OPTIONS] URL [URL...]", + Aliases: []string{"rm", "uninstall"}, + Short: "Remove one or more applications", + Args: cli.RequiresMinArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runRemove(dockerCli, args, options) + }, + Annotations: map[string]string{ + "aliases": "docker app rm, docker app uninstall", + }, + } + + options = newRemoveOptions() + + return cmd +} + +// runRemove removes the specified apps installed under the default app base. +// run uninstall script if found under the package path +// remove all files under the package path +// remove symlinks of the app under bin path +func runRemove(dockerCli command.Cli, apps []string, options *removeOptions) error { + binPath := options.binPath() + targets, err := findSymlinks(binPath) + if err != nil { + return err + } + + var failed []string + + for _, app := range apps { + options.setArgs([]string{app}) + appPath, err := options.appPath() + if err != nil { + failed = append(failed, app) + continue + } + + // optionally run uninstall if provided + envs, _ := options.makeEnvs() + runUninstaller(dockerCli, appPath, envs) + + // remove all files under the app path + if err := os.RemoveAll(appPath); err != nil { + failed = append(failed, app) + continue + } + removeEmptyPath(options.pkgPath(), appPath) + fmt.Fprintf(dockerCli.Out(), "app package removed %s\n", appPath) + + cleanupSymlink(dockerCli, appPath, targets) + } + + if len(failed) > 0 { + return fmt.Errorf("failed to remove some apps: %v", failed) + } + return nil +} + +// find all symlinks in binPath for removal +func findSymlinks(binPath string) (map[string]string, error) { + targets := make(map[string]string) + readlink := func(link string) (string, error) { + target, err := os.Readlink(link) + if err != nil { + return "", err + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(link), target) + } + abs, err := filepath.Abs(target) + if err != nil { + return "", err + } + return abs, nil + } + if links, err := findLinks(binPath); err == nil { + for _, link := range links { + if target, err := readlink(link); err == nil { + targets[target] = link + } else { + return nil, err + } + } + } + return targets, nil +} + +// runUninstaller optionally runs uninstall if provided +func runUninstaller(dockerCli command.Cli, appPath string, envs map[string]string) { + uninstaller := filepath.Join(appPath, uninstallerName) + if _, err := os.Stat(uninstaller); err == nil { + err := spawn(uninstaller, nil, envs, false) + if err != nil { + fmt.Fprintf(dockerCli.Err(), "%s failed to run: %v\n", uninstaller, err) + } + } +} + +// cleanupSymlink removes symlinks of the app if any +func cleanupSymlink(dockerCli command.Cli, appPath string, targets map[string]string) { + owns := func(app, target string) bool { + return strings.Contains(target, app) + } + for target, link := range targets { + if owns(appPath, target) { + if err := os.Remove(link); err != nil { + fmt.Fprintf(dockerCli.Err(), "failed to remove %s: %v\n", link, err) + } else { + fmt.Fprintf(dockerCli.Out(), "app symlink removed %s\n", link) + } + } + } +} diff --git a/cli/command/app/remove_test.go b/cli/command/app/remove_test.go new file mode 100644 index 000000000000..3645d8076925 --- /dev/null +++ b/cli/command/app/remove_test.go @@ -0,0 +1,128 @@ +package app + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/internal/test" + "gotest.tools/v3/assert" +) + +func TestRunRemove(t *testing.T) { + appBase := t.TempDir() + t.Setenv("DOCKER_APP_BASE", appBase) + + binPath := filepath.Join(appBase, "bin") + pkgPath := filepath.Join(appBase, "pkg") + err := os.MkdirAll(binPath, 0o755) + assert.NilError(t, err) + err = os.MkdirAll(pkgPath, 0o755) + assert.NilError(t, err) + + exist := func(p string) bool { + _, err := os.Stat(p) + return err == nil + } + + create := func(p string) error { + err := os.MkdirAll(filepath.Dir(p), 0o755) + if err != nil { + return err + } + f, err := os.Create(p) + if err != nil { + return err + } + err = f.Close() + return err + } + + createApp := func(name string, args []string) ([]string, error) { + o := &AppOptions{ + commonOptions: commonOptions{ + _appBase: appBase, + _args: args, + }, + } + appPath, err := o.appPath() + if err != nil { + return nil, err + } + target := filepath.Join(appPath, name) + link := filepath.Join(o.binPath(), name) + err = create(target) + if err != nil { + return nil, err + } + err = os.Symlink(target, link) + if err != nil { + return nil, err + } + return []string{link, target}, nil + } + + tests := []struct { + name string + args []string + fakeInstall func([]string) []string + expectErr string + }{ + { + name: "one app", args: []string{"example.com/org/cool"}, + fakeInstall: func(args []string) []string { + files, err := createApp("cool", args) + assert.NilError(t, err) + return files + }, + expectErr: "", + }, + { + name: "a few apps", args: []string{"example.com/org/one", "example.com/org/two", "example.com/org/three@v1.2.3"}, + fakeInstall: func(args []string) []string { + var files []string + for _, a := range args { + f, err := createApp(filepath.Base(a), []string{a}) + assert.NilError(t, err) + files = append(files, f...) + } + return files + }, expectErr: "", + }, + { + name: "none", args: []string{}, + fakeInstall: func(args []string) []string { + return nil + }, + expectErr: `"remove" requires at least 1 argument`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + files := tc.fakeInstall(tc.args) + + // make sure the files exist + for _, f := range files { + assert.Assert(t, exist(f)) + } + + cli := test.NewFakeCli(nil) + cmd := NewRemoveCommand(cli) + cmd.SetArgs(tc.args) + cmd.SetOut(io.Discard) + err := cmd.Execute() + + if tc.expectErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectErr) + } + + // assert the installed files are removed + for _, f := range files { + assert.Check(t, !exist(f)) + } + }) + } +} diff --git a/cli/command/app/utils.go b/cli/command/app/utils.go new file mode 100644 index 000000000000..271cd2250018 --- /dev/null +++ b/cli/command/app/utils.go @@ -0,0 +1,273 @@ +package app + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" +) + +// spawn runs the specified command +func spawn(bin string, args []string, envMap map[string]string, detach bool) error { + toEnv := func() []string { + var env []string + for k, v := range envMap { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + return env + } + + cmd := exec.Command(bin, args...) + cmd.Env = append(os.Environ(), toEnv()...) + cmd.Dir = filepath.Dir(bin) + if detach { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + err := cmd.Start() + if err != nil { + return err + } + return nil + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + err := cmd.Start() + if err != nil { + return err + } + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + select { + case sig := <-sigs: + if cmd.Process != nil { + cmd.Process.Signal(sig) + } + return fmt.Errorf("signal received: %v", sig) + case err := <-done: + if err != nil { + return err + } + } + } + return nil +} + +// oneChild checks if the directory contains only one single file. +// if true, return the file path +func oneChild(dir string) (string, error) { + dirs, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + var fp string + cnt := 0 + for _, v := range dirs { + if !v.IsDir() { + cnt++ + if cnt > 1 { + break + } + fp = filepath.Join(dir, v.Name()) + } + } + if cnt != 1 { + return "", nil + } + + ap, err := filepath.Abs(fp) + if err != nil { + return "", err + } + return ap, nil +} + +// locateFile searches for the filename in a given directory +// if found, return its file path +func locateFile(dir, name string) (string, error) { + dirs, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + for _, entry := range dirs { + if !entry.IsDir() && entry.Name() == name { + fp := filepath.Join(dir, entry.Name()) + ap, err := filepath.Abs(fp) + if err != nil { + return "", err + } + return ap, nil + } + } + return "", nil +} + +// parseURL normalizes the given string as URL +// currently supported schemes: file, http, https, git +func parseURL(s string) (*url.URL, error) { + if !strings.Contains(s, "://") { + ap, err := filepath.Abs(s) + if err != nil { + return nil, err + } + s = "file://" + ap + } + + parsed, err := url.Parse(s) + if err != nil { + return nil, err + } + switch parsed.Scheme { + case "file", "http", "https", "git": + return parsed, nil + default: + return nil, fmt.Errorf("not supported: %s", s) + } +} + +// isSymlinkOK checks if it is ok to create a symlink to the target +// it is ok if the path does not exist +// or if the path is a symlink that points to the same target. +// it is considered the same target if the symlink is identical up to +// the first @ or # sign, i.e. they are the same app but different versions. +func isSymlinkOK(path, target string) (bool, error) { + fi, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return true, nil + } + return false, err + } + + if fi.Mode()&os.ModeSymlink == 0 { + return false, fmt.Errorf("another app/version exists: %s", path) + } + + link, err := os.Readlink(path) + if err != nil { + return false, err + } + + pkg := func(s string) string { + s = filepath.Dir(s) + s = strings.Split(s, "#")[0] + return strings.Split(s, "@")[0] + } + + return pkg(link) == pkg(target), nil +} + +// splitAtDashDash splits a string array into two parts +// at the first double dash "--" +func splitAtDashDash(arr []string) ([]string, []string) { + for i, v := range arr { + if v == "--" { + if i+1 < len(arr) { + return arr[:i], arr[i+1:] + } + return arr[:i], []string{} + } + } + return arr, []string{} +} + +// findLinks returns a list of symlinks in the given directory +func findLinks(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var links []string + for _, v := range entries { + p := filepath.Join(dir, v.Name()) + if v.Type()&os.ModeSymlink != 0 { + links = append(links, p) + } + } + + return links, nil +} + +// removeEmptyPath removes the dir and all its ancestors if empty +// until it reaches the root +func removeEmptyPath(root, dir string) error { + root = filepath.Clean(root) + dir = filepath.Clean(dir) + + if !strings.HasPrefix(dir, root) { + return nil + } + + var rm func(string) error + rm = func(p string) error { + if p == root { + return nil + } + if err := os.Remove(p); err != nil { + if !os.IsNotExist(err) { + return err + } + } + parent := filepath.Dir(p) + return rm(parent) + } + + return rm(filepath.Dir(dir)) +} + +// shortenPath removes home directory from path to make it shorter +func shortenPath(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return strings.ReplaceAll(path, home+"/", "_") +} + +// locateDir walks back the path and looks for directory with the given name. +// If found, it returns the directory; otherwise an empty string. +func locateDir(path, name string) (string, error) { + check := func(dir string) (bool, string) { + if filepath.Base(dir) == name { + return true, dir + } + child := filepath.Join(dir, name) + info, err := os.Stat(child) + if err != nil { + return false, "" + } + return info.IsDir(), child + } + + dir, err := filepath.Abs(path) + if err != nil { + return "", err + } + + for { + if found, d := check(dir); found { + return d, nil + } + + parent := filepath.Dir(dir) + if parent == "/" || parent == dir { + break + } + dir = parent + } + + return "", fmt.Errorf("not found: %s", name) +} diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index 23a43568b51f..630a3ab85517 100644 --- a/cli/command/commands/commands.go +++ b/cli/command/commands/commands.go @@ -4,6 +4,7 @@ import ( "os" "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/app" "github.com/docker/cli/cli/command/builder" "github.com/docker/cli/cli/command/checkpoint" "github.com/docker/cli/cli/command/config" @@ -43,6 +44,7 @@ func AddCommands(cmd *cobra.Command, dockerCli command.Cli) { system.NewInfoCommand(dockerCli), // management commands + app.NewAppCommand(dockerCli), builder.NewBuilderCommand(dockerCli), checkpoint.NewCheckpointCommand(dockerCli), container.NewContainerCommand(dockerCli), diff --git a/cli/command/container/cp.go b/cli/command/container/cp.go index b361e2678cdc..5aa5c21e43cf 100644 --- a/cli/command/container/cp.go +++ b/cli/command/container/cp.go @@ -22,9 +22,11 @@ import ( "github.com/morikuni/aec" "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) -type copyOptions struct { +// CopyOptions defines copy options +type CopyOptions struct { source string destination string followLink bool @@ -32,6 +34,14 @@ type copyOptions struct { quiet bool } +func (o *CopyOptions) SetSource(s string) { + o.source = s +} + +func (o *CopyOptions) SetDestination(d string) { + o.destination = d +} + type copyDirection int const ( @@ -124,7 +134,7 @@ func copyProgress(ctx context.Context, dst io.Writer, header string, total *int6 // NewCopyCommand creates a new `docker cp` command func NewCopyCommand(dockerCli command.Cli) *cobra.Command { - var opts copyOptions + var opts *CopyOptions cmd := &cobra.Command{ Use: `cp [OPTIONS] CONTAINER:SRC_PATH DEST_PATH|- @@ -151,7 +161,7 @@ func NewCopyCommand(dockerCli command.Cli) *cobra.Command { // User did not specify "quiet" flag; suppress output if no terminal is attached opts.quiet = !dockerCli.Out().IsTerminal() } - return runCopy(cmd.Context(), dockerCli, opts) + return RunCopy(cmd.Context(), dockerCli, opts) }, Annotations: map[string]string{ "aliases": "docker container cp, docker cp", @@ -159,17 +169,27 @@ func NewCopyCommand(dockerCli command.Cli) *cobra.Command { } flags := cmd.Flags() + opts = AddCopyFlags(flags) + + return cmd +} + +// AddCopyFlags adds copy flags to the FlagSet +func AddCopyFlags(flags *pflag.FlagSet) *CopyOptions { + var opts CopyOptions + flags.BoolVarP(&opts.followLink, "follow-link", "L", false, "Always follow symbol link in SRC_PATH") flags.BoolVarP(&opts.copyUIDGID, "archive", "a", false, "Archive mode (copy all uid/gid information)") flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress progress output during copy. Progress output is automatically suppressed if no terminal is attached") - return cmd + + return &opts } func progressHumanSize(n int64) string { return units.HumanSizeWithPrecision(float64(n), 3) } -func runCopy(ctx context.Context, dockerCli command.Cli, opts copyOptions) error { +func RunCopy(ctx context.Context, dockerCli command.Cli, opts *CopyOptions) error { srcContainer, srcPath := splitCpArg(opts.source) destContainer, destPath := splitCpArg(opts.destination) diff --git a/cli/command/container/cp_test.go b/cli/command/container/cp_test.go index d6a87bc3669c..89153796798b 100644 --- a/cli/command/container/cp_test.go +++ b/cli/command/container/cp_test.go @@ -19,12 +19,12 @@ import ( func TestRunCopyWithInvalidArguments(t *testing.T) { testcases := []struct { doc string - options copyOptions + options CopyOptions expectedErr string }{ { doc: "copy between container", - options: copyOptions{ + options: CopyOptions{ source: "first:/path", destination: "second:/path", }, @@ -32,7 +32,7 @@ func TestRunCopyWithInvalidArguments(t *testing.T) { }, { doc: "copy without a container", - options: copyOptions{ + options: CopyOptions{ source: "./source", destination: "./dest", }, @@ -41,7 +41,7 @@ func TestRunCopyWithInvalidArguments(t *testing.T) { } for _, testcase := range testcases { t.Run(testcase.doc, func(t *testing.T) { - err := runCopy(context.TODO(), test.NewFakeCli(nil), testcase.options) + err := RunCopy(context.TODO(), test.NewFakeCli(nil), &testcase.options) assert.Error(t, err, testcase.expectedErr) }) } @@ -56,7 +56,7 @@ func TestRunCopyFromContainerToStdout(t *testing.T) { return io.NopCloser(strings.NewReader(tarContent)), container.PathStat{}, nil }, }) - err := runCopy(context.TODO(), cli, copyOptions{ + err := RunCopy(context.TODO(), cli, &CopyOptions{ source: "container:/path", destination: "-", }) @@ -77,7 +77,7 @@ func TestRunCopyFromContainerToFilesystem(t *testing.T) { return readCloser, container.PathStat{}, err }, }) - err := runCopy(context.TODO(), cli, copyOptions{ + err := RunCopy(context.TODO(), cli, &CopyOptions{ source: "container:/path", destination: destDir.Path(), quiet: true, @@ -103,7 +103,7 @@ func TestRunCopyFromContainerToFilesystemMissingDestinationDirectory(t *testing. return readCloser, container.PathStat{}, err }, }) - err := runCopy(context.TODO(), cli, copyOptions{ + err := RunCopy(context.TODO(), cli, &CopyOptions{ source: "container:/path", destination: destDir.Join("missing", "foo"), }) @@ -115,7 +115,7 @@ func TestRunCopyToContainerFromFileWithTrailingSlash(t *testing.T) { defer srcFile.Remove() cli := test.NewFakeCli(&fakeClient{}) - err := runCopy(context.TODO(), cli, copyOptions{ + err := RunCopy(context.TODO(), cli, &CopyOptions{ source: srcFile.Path() + string(os.PathSeparator), destination: "container:/path", }) @@ -129,7 +129,7 @@ func TestRunCopyToContainerFromFileWithTrailingSlash(t *testing.T) { func TestRunCopyToContainerSourceDoesNotExist(t *testing.T) { cli := test.NewFakeCli(&fakeClient{}) - err := runCopy(context.TODO(), cli, copyOptions{ + err := RunCopy(context.TODO(), cli, &CopyOptions{ source: "/does/not/exist", destination: "container:/path", }) @@ -196,7 +196,7 @@ func TestSplitCpArg(t *testing.T) { func TestRunCopyFromContainerToFilesystemIrregularDestination(t *testing.T) { cli := test.NewFakeCli(nil) - err := runCopy(context.TODO(), cli, copyOptions{ + err := RunCopy(context.TODO(), cli, &CopyOptions{ source: "container:/dev/null", destination: "/dev/random", }) diff --git a/cli/command/container/create.go b/cli/command/container/create.go index 3193ccf20072..8665507fbca1 100644 --- a/cli/command/container/create.go +++ b/cli/command/container/create.go @@ -44,7 +44,7 @@ type createOptions struct { // NewCreateCommand creates a new cobra.Command for `docker create` func NewCreateCommand(dockerCli command.Cli) *cobra.Command { var options createOptions - var copts *containerOptions + var copts *ContainerOptions cmd := &cobra.Command{ Use: "create [OPTIONS] IMAGE [COMMAND] [ARG...]", @@ -76,11 +76,11 @@ func NewCreateCommand(dockerCli command.Cli) *cobra.Command { command.AddPlatformFlag(flags, &options.platform) command.AddTrustVerificationFlags(flags, &options.untrusted, dockerCli.ContentTrustEnabled()) - copts = addFlags(flags) + copts = AddFlags(flags) return cmd } -func runCreate(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, options *createOptions, copts *containerOptions) error { +func runCreate(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, options *createOptions, copts *ContainerOptions) error { if err := validatePullOpt(options.pull); err != nil { reportError(dockerCli.Err(), "create", err.Error(), true) return cli.StatusError{StatusCode: 125} diff --git a/cli/command/container/create_test.go b/cli/command/container/create_test.go index 43509a9b007e..7b7ff7dd733e 100644 --- a/cli/command/container/create_test.go +++ b/cli/command/container/create_test.go @@ -184,7 +184,7 @@ func TestCreateContainerImagePullPolicyInvalid(t *testing.T) { dockerCli, &pflag.FlagSet{}, &createOptions{pull: tc.PullPolicy}, - &containerOptions{}, + &ContainerOptions{}, ) statusErr := cli.StatusError{} diff --git a/cli/command/container/opts.go b/cli/command/container/opts.go index 2a49259d2914..22a2ba179d0b 100644 --- a/cli/command/container/opts.go +++ b/cli/command/container/opts.go @@ -44,8 +44,8 @@ const ( var deviceCgroupRuleRegexp = regexp.MustCompile(`^[acb] ([0-9]+|\*):([0-9]+|\*) [rwm]{1,3}$`) -// containerOptions is a data object with all the options for creating a container -type containerOptions struct { +// ContainerOptions is a data object with all the options for creating a container +type ContainerOptions struct { attach opts.ListOpts volumes opts.ListOpts tmpfs opts.ListOpts @@ -145,9 +145,13 @@ type containerOptions struct { Args []string } -// addFlags adds all command line flags that will be used by parse to the FlagSet -func addFlags(flags *pflag.FlagSet) *containerOptions { - copts := &containerOptions{ +func (o *ContainerOptions) SetContainerIDFile(cidfile string) { + o.containerIDFile = cidfile +} + +// AddFlags adds all command line flags that will be used by parse to the FlagSet +func AddFlags(flags *pflag.FlagSet) *ContainerOptions { + copts := &ContainerOptions{ aliases: opts.NewListOpts(nil), attach: opts.NewListOpts(validateAttach), blkioWeightDevice: opts.NewWeightdeviceOpt(opts.ValidateWeightDevice), @@ -335,7 +339,7 @@ type containerConfig struct { // If the specified args are not valid, it will return an error. // //nolint:gocyclo -func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { +func parse(flags *pflag.FlagSet, copts *ContainerOptions, serverOS string) (*containerConfig, error) { var ( attachStdin = copts.attach.Get("stdin") attachStdout = copts.attach.Get("stdout") @@ -746,7 +750,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con // this function may return _multiple_ endpoints, which is not currently supported // by the daemon, but may be in future; it's up to the daemon to produce an error // in case that is not supported. -func parseNetworkOpts(copts *containerOptions) (map[string]*networktypes.EndpointSettings, error) { +func parseNetworkOpts(copts *ContainerOptions) (map[string]*networktypes.EndpointSettings, error) { var ( endpoints = make(map[string]*networktypes.EndpointSettings, len(copts.netMode.Value())) hasUserDefined, hasNonUserDefined bool @@ -807,7 +811,7 @@ func parseNetworkOpts(copts *containerOptions) (map[string]*networktypes.Endpoin return endpoints, nil } -func applyContainerOptions(n *opts.NetworkAttachmentOpts, copts *containerOptions) error { //nolint:gocyclo +func applyContainerOptions(n *opts.NetworkAttachmentOpts, copts *ContainerOptions) error { //nolint:gocyclo // TODO should we error if _any_ advanced option is used? (i.e. forbid to combine advanced notation with the "old" flags (`--network-alias`, `--link`, `--ip`, `--ip6`)? if len(n.Aliases) > 0 && copts.aliases.Len() > 0 { return errdefs.InvalidParameter(errors.New("conflicting options: cannot specify both --network-alias and per-network alias")) diff --git a/cli/command/container/opts_test.go b/cli/command/container/opts_test.go index e03c26530e2f..8245bdde1075 100644 --- a/cli/command/container/opts_test.go +++ b/cli/command/container/opts_test.go @@ -56,11 +56,11 @@ func parseRun(args []string) (*container.Config, *container.HostConfig, *network return containerCfg.Config, containerCfg.HostConfig, containerCfg.NetworkingConfig, err } -func setupRunFlags() (*pflag.FlagSet, *containerOptions) { +func setupRunFlags() (*pflag.FlagSet, *ContainerOptions) { flags := pflag.NewFlagSet("run", pflag.ContinueOnError) flags.SetOutput(io.Discard) flags.Usage = nil - copts := addFlags(flags) + copts := AddFlags(flags) return flags, copts } diff --git a/cli/command/container/run.go b/cli/command/container/run.go index 562e8029208b..930b18db0fff 100644 --- a/cli/command/container/run.go +++ b/cli/command/container/run.go @@ -21,7 +21,8 @@ import ( "github.com/spf13/pflag" ) -type runOptions struct { +// RunOptions defines run options +type RunOptions struct { createOptions detach bool sigProxy bool @@ -30,8 +31,8 @@ type runOptions struct { // NewRunCommand create a new `docker run` command func NewRunCommand(dockerCli command.Cli) *cobra.Command { - var options runOptions - var copts *containerOptions + var options *RunOptions + var copts *ContainerOptions cmd := &cobra.Command{ Use: "run [OPTIONS] IMAGE [COMMAND] [ARG...]", @@ -42,7 +43,7 @@ func NewRunCommand(dockerCli command.Cli) *cobra.Command { if len(args) > 1 { copts.Args = args[1:] } - return runRun(cmd.Context(), dockerCli, cmd.Flags(), &options, copts) + return RunRun(cmd.Context(), dockerCli, cmd.Flags(), options, copts) }, ValidArgsFunction: completion.ImageNames(dockerCli), Annotations: map[string]string{ @@ -54,21 +55,8 @@ func NewRunCommand(dockerCli command.Cli) *cobra.Command { flags := cmd.Flags() flags.SetInterspersed(false) - // These are flags not stored in Config/HostConfig - flags.BoolVarP(&options.detach, "detach", "d", false, "Run container in background and print container ID") - flags.BoolVar(&options.sigProxy, "sig-proxy", true, "Proxy received signals to the process") - flags.StringVar(&options.name, "name", "", "Assign a name to the container") - flags.StringVar(&options.detachKeys, "detach-keys", "", "Override the key sequence for detaching a container") - flags.StringVar(&options.pull, "pull", PullImageMissing, `Pull image before running ("`+PullImageAlways+`", "`+PullImageMissing+`", "`+PullImageNever+`")`) - flags.BoolVarP(&options.quiet, "quiet", "q", false, "Suppress the pull output") - - // Add an explicit help that doesn't have a `-h` to prevent the conflict - // with hostname - flags.Bool("help", false, "Print usage") - - command.AddPlatformFlag(flags, &options.platform) - command.AddTrustVerificationFlags(flags, &options.untrusted, dockerCli.ContentTrustEnabled()) - copts = addFlags(flags) + options = AddRunFlags(flags, dockerCli.ContentTrustEnabled()) + copts = AddFlags(flags) cmd.RegisterFlagCompletionFunc( "env", @@ -86,10 +74,33 @@ func NewRunCommand(dockerCli command.Cli) *cobra.Command { "network", completion.NetworkNames(dockerCli), ) + return cmd } -func runRun(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, ropts *runOptions, copts *containerOptions) error { +// AddRunFlags adds run flags to the FlagSet +func AddRunFlags(flags *pflag.FlagSet, trust bool) *RunOptions { + var options RunOptions + + // These are flags not stored in Config/HostConfig + flags.BoolVarP(&options.detach, "detach", "d", false, "Run container in background and print container ID") + flags.BoolVar(&options.sigProxy, "sig-proxy", true, "Proxy received signals to the process") + flags.StringVar(&options.name, "name", "", "Assign a name to the container") + flags.StringVar(&options.detachKeys, "detach-keys", "", "Override the key sequence for detaching a container") + flags.StringVar(&options.pull, "pull", PullImageMissing, `Pull image before running ("`+PullImageAlways+`", "`+PullImageMissing+`", "`+PullImageNever+`")`) + flags.BoolVarP(&options.quiet, "quiet", "q", false, "Suppress the pull output") + + // Add an explicit help that doesn't have a `-h` to prevent the conflict + // with hostname + flags.Bool("help", false, "Print usage") + + command.AddPlatformFlag(flags, &options.platform) + command.AddTrustVerificationFlags(flags, &options.untrusted, trust) + + return &options +} + +func RunRun(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, ropts *RunOptions, copts *ContainerOptions) error { if err := validatePullOpt(ropts.pull); err != nil { reportError(dockerCli.Err(), "run", err.Error(), true) return cli.StatusError{StatusCode: 125} @@ -118,9 +129,7 @@ func runRun(ctx context.Context, dockerCli command.Cli, flags *pflag.FlagSet, ro } //nolint:gocyclo -func runContainer(ctx context.Context, dockerCli command.Cli, runOpts *runOptions, copts *containerOptions, containerCfg *containerConfig) error { - ctx = context.WithoutCancel(ctx) - +func runContainer(ctx context.Context, dockerCli command.Cli, runOpts *RunOptions, copts *ContainerOptions, containerCfg *containerConfig) error { config := containerCfg.Config stdout, stderr := dockerCli.Out(), dockerCli.Err() apiClient := dockerCli.Client() @@ -180,9 +189,6 @@ func runContainer(ctx context.Context, dockerCli command.Cli, runOpts *runOption detachKeys = runOpts.detachKeys } - // ctx should not be cancellable here, as this would kill the stream to the container - // and we want to keep the stream open until the process in the container exits or until - // the user forcefully terminates the CLI. closeFn, err := attachContainer(ctx, dockerCli, containerID, &errCh, config, container.AttachOptions{ Stream: true, Stdin: config.AttachStdin, diff --git a/cli/command/container/run_test.go b/cli/command/container/run_test.go index 7c39b17ca67b..6f57fbc9a4d6 100644 --- a/cli/command/container/run_test.go +++ b/cli/command/container/run_test.go @@ -169,12 +169,12 @@ func TestRunContainerImagePullPolicyInvalid(t *testing.T) { tc := tc t.Run(tc.PullPolicy, func(t *testing.T) { dockerCli := test.NewFakeCli(&fakeClient{}) - err := runRun( + err := RunRun( context.TODO(), dockerCli, &pflag.FlagSet{}, - &runOptions{createOptions: createOptions{pull: tc.PullPolicy}}, - &containerOptions{}, + &RunOptions{createOptions: createOptions{pull: tc.PullPolicy}}, + &ContainerOptions{}, ) statusErr := cli.StatusError{} diff --git a/cli/command/image/build.go b/cli/command/image/build.go index cb5292be85c0..a1e40de71d29 100644 --- a/cli/command/image/build.go +++ b/cli/command/image/build.go @@ -32,9 +32,11 @@ import ( "github.com/docker/docker/pkg/streamformatter" "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) -type buildOptions struct { +// BuildOptions defines the options for the build command +type BuildOptions struct { context string dockerfileName string tags opts.ListOpts @@ -70,19 +72,31 @@ type buildOptions struct { // dockerfileFromStdin returns true when the user specified that the Dockerfile // should be read from stdin instead of a file -func (o buildOptions) dockerfileFromStdin() bool { +func (o BuildOptions) dockerfileFromStdin() bool { return o.dockerfileName == "-" } // contextFromStdin returns true when the user specified that the build context // should be read from stdin -func (o buildOptions) contextFromStdin() bool { +func (o BuildOptions) contextFromStdin() bool { return o.context == "-" } -func newBuildOptions() buildOptions { +func (o *BuildOptions) SetContext(ctx string) { + o.context = ctx +} + +func (o *BuildOptions) SetImageIDFile(imageIDFile string) { + o.imageIDFile = imageIDFile +} + +func (o *BuildOptions) SetBuildArg(value string) { + o.buildArgs.Set(value) +} + +func newBuildOptions() BuildOptions { ulimits := make(map[string]*container.Ulimit) - return buildOptions{ + return BuildOptions{ tags: opts.NewListOpts(validateTag), buildArgs: opts.NewListOpts(opts.ValidateEnv), ulimits: opts.NewUlimitOpt(&ulimits), @@ -93,7 +107,7 @@ func newBuildOptions() buildOptions { // NewBuildCommand creates a new `docker build` command func NewBuildCommand(dockerCli command.Cli) *cobra.Command { - options := newBuildOptions() + var options *BuildOptions cmd := &cobra.Command{ Use: "build [OPTIONS] PATH | URL | -", @@ -101,7 +115,7 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command { Args: cli.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { options.context = args[0] - return runBuild(cmd.Context(), dockerCli, options) + return RunBuild(cmd.Context(), dockerCli, options) }, Annotations: map[string]string{ "category-top": "4", @@ -113,6 +127,14 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command { } flags := cmd.Flags() + options = AddBuildFlags(flags, dockerCli.ContentTrustEnabled()) + + return cmd +} + +// AddBuildFlags adds build flags to the given FlagSet +func AddBuildFlags(flags *pflag.FlagSet, trust bool) *BuildOptions { + options := newBuildOptions() flags.VarP(&options.tags, "tag", "t", `Name and optionally a tag in the "name:tag" format`) flags.SetAnnotation("tag", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#tag"}) @@ -150,7 +172,7 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command { flags.SetAnnotation("target", annotation.ExternalURL, []string{"https://docs.docker.com/reference/cli/docker/buildx/build/#target"}) flags.StringVar(&options.imageIDFile, "iidfile", "", "Write the image ID to the file") - command.AddTrustVerificationFlags(flags, &options.untrusted, dockerCli.ContentTrustEnabled()) + command.AddTrustVerificationFlags(flags, &options.untrusted, trust) flags.StringVar(&options.platform, "platform", os.Getenv("DOCKER_DEFAULT_PLATFORM"), "Set platform if server is multi-platform capable") flags.SetAnnotation("platform", "version", []string{"1.38"}) @@ -159,7 +181,7 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command { flags.SetAnnotation("squash", "experimental", nil) flags.SetAnnotation("squash", "version", []string{"1.25"}) - return cmd + return &options } // lastProgressOutput is the same as progress.Output except @@ -179,7 +201,7 @@ func (out *lastProgressOutput) WriteProgress(prog progress.Progress) error { } //nolint:gocyclo -func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions) error { +func RunBuild(ctx context.Context, dockerCli command.Cli, options *BuildOptions) error { var ( err error buildCtx io.ReadCloser @@ -536,7 +558,7 @@ func replaceDockerfileForContentTrust(ctx context.Context, inputTarStream io.Rea return pipeReader } -func imageBuildOptions(dockerCli command.Cli, options buildOptions) types.ImageBuildOptions { +func imageBuildOptions(dockerCli command.Cli, options *BuildOptions) types.ImageBuildOptions { configFile := dockerCli.ConfigFile() return types.ImageBuildOptions{ Memory: options.memory.Value(), diff --git a/cli/command/image/build_test.go b/cli/command/image/build_test.go index b13ba3b0c1db..08e52a268c89 100644 --- a/cli/command/image/build_test.go +++ b/cli/command/image/build_test.go @@ -48,7 +48,7 @@ func TestRunBuildDockerfileFromStdinWithCompress(t *testing.T) { options.dockerfileName = "-" options.context = dir.Path() options.untrusted = true - assert.NilError(t, runBuild(context.TODO(), cli, options)) + assert.NilError(t, RunBuild(context.TODO(), cli, &options)) expected := []string{fakeBuild.options.Dockerfile, ".dockerignore", "foo"} assert.DeepEqual(t, expected, fakeBuild.filenames(t)) @@ -75,7 +75,7 @@ func TestRunBuildResetsUidAndGidInContext(t *testing.T) { options := newBuildOptions() options.context = dir.Path() options.untrusted = true - assert.NilError(t, runBuild(context.TODO(), cli, options)) + assert.NilError(t, RunBuild(context.TODO(), cli, &options)) headers := fakeBuild.headers(t) expected := []*tar.Header{ @@ -110,7 +110,7 @@ COPY data /data options.context = dir.Path() options.dockerfileName = df.Path() options.untrusted = true - assert.NilError(t, runBuild(context.TODO(), cli, options)) + assert.NilError(t, RunBuild(context.TODO(), cli, &options)) expected := []string{fakeBuild.options.Dockerfile, ".dockerignore", "data"} assert.DeepEqual(t, expected, fakeBuild.filenames(t)) @@ -171,7 +171,7 @@ RUN echo hello world options := newBuildOptions() options.context = tmpDir.Join("context-link") options.untrusted = true - assert.NilError(t, runBuild(context.TODO(), cli, options)) + assert.NilError(t, RunBuild(context.TODO(), cli, &options)) assert.DeepEqual(t, fakeBuild.filenames(t), []string{"Dockerfile"}) } diff --git a/cli/command/image/push.go b/cli/command/image/push.go index d28fd93e7a41..6abacbf71d3b 100644 --- a/cli/command/image/push.go +++ b/cli/command/image/push.go @@ -8,7 +8,7 @@ import ( "encoding/json" "fmt" "io" - "strings" + "os" "github.com/containerd/platforms" "github.com/distribution/reference" @@ -58,13 +58,8 @@ func NewPushCommand(dockerCli command.Cli) *cobra.Command { flags.BoolVarP(&opts.all, "all-tags", "a", false, "Push all tags of an image to the repository") flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress verbose output") command.AddTrustSigningFlags(flags, &opts.untrusted, dockerCli.ContentTrustEnabled()) - - // Don't default to DOCKER_DEFAULT_PLATFORM env variable, always default to - // pushing the image as-is. This also avoids forcing the platform selection - // on older APIs which don't support it. - flags.StringVar(&opts.platform, "platform", "", + flags.StringVar(&opts.platform, "platform", os.Getenv("DOCKER_DEFAULT_PLATFORM"), `Push a platform-specific manifest as a single-platform image to the registry. -Image index won't be pushed, meaning that other manifests, including attestations won't be preserved. 'os[/arch[/variant]]': Explicit platform (eg. linux/amd64)`) flags.SetAnnotation("platform", "version", []string{"1.46"}) @@ -84,9 +79,9 @@ func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error } platform = &p - printNote(dockerCli, `Using --platform pushes only the specified platform manifest of a multi-platform image index. -Other components, like attestations, will not be included. -To push the complete multi-platform image, remove the --platform flag. + printNote(dockerCli, `Selecting a single platform will only push one matching image manifest from a multi-platform image index. +This means that any other components attached to the multi-platform image index (like Buildkit attestations) won't be pushed. +If you want to only push a single platform image while preserving the attestations, please use 'docker convert\n' `) } @@ -184,22 +179,9 @@ func handleAux(dockerCli command.Cli) func(jm jsonmessage.JSONMessage) { func printNote(dockerCli command.Cli, format string, args ...any) { if dockerCli.Err().IsTerminal() { - format = strings.ReplaceAll(format, "--platform", aec.Bold.Apply("--platform")) - } - - header := " Info -> " - padding := len(header) - if dockerCli.Err().IsTerminal() { - padding = len("i Info > ") - header = aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → ")) - } - - _, _ = fmt.Fprint(dockerCli.Err(), header) - s := fmt.Sprintf(format, args...) - for idx, line := range strings.Split(s, "\n") { - if idx > 0 { - _, _ = fmt.Fprint(dockerCli.Err(), strings.Repeat(" ", padding)) - } - _, _ = fmt.Fprintln(dockerCli.Err(), aec.Italic.Apply(line)) + _, _ = fmt.Fprint(dockerCli.Err(), aec.WhiteF.Apply(aec.CyanB.Apply("[ NOTE ]"))+" ") + } else { + _, _ = fmt.Fprint(dockerCli.Err(), "[ NOTE ] ") } + _, _ = fmt.Fprintf(dockerCli.Err(), aec.Bold.Apply(format)+"\n", args...) } diff --git a/docs/reference/commandline/image_push.md b/docs/reference/commandline/image_push.md index 91f6a41be1f0..d58282a1accc 100644 --- a/docs/reference/commandline/image_push.md +++ b/docs/reference/commandline/image_push.md @@ -9,12 +9,12 @@ Upload an image to a registry ### Options -| Name | Type | Default | Description | -|:---------------------------------------------|:---------|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [`-a`](#all-tags), [`--all-tags`](#all-tags) | `bool` | | Push all tags of an image to the repository | -| `--disable-content-trust` | `bool` | `true` | Skip image signing | -| `--platform` | `string` | | Push a platform-specific manifest as a single-platform image to the registry.
Image index won't be pushed, meaning that other manifests, including attestations won't be preserved.
'os[/arch[/variant]]': Explicit platform (eg. linux/amd64) | -| `-q`, `--quiet` | `bool` | | Suppress verbose output | +| Name | Type | Default | Description | +|:---------------------------------------------|:---------|:--------|:--------------------------------------------------------------------------------------------------------------------------------------------| +| [`-a`](#all-tags), [`--all-tags`](#all-tags) | `bool` | | Push all tags of an image to the repository | +| `--disable-content-trust` | `bool` | `true` | Skip image signing | +| `--platform` | `string` | | Push a platform-specific manifest as a single-platform image to the registry.
'os[/arch[/variant]]': Explicit platform (eg. linux/amd64) | +| `-q`, `--quiet` | `bool` | | Suppress verbose output | diff --git a/docs/reference/commandline/push.md b/docs/reference/commandline/push.md index 9558d38e5ebc..2d8358d6ff7f 100644 --- a/docs/reference/commandline/push.md +++ b/docs/reference/commandline/push.md @@ -9,12 +9,12 @@ Upload an image to a registry ### Options -| Name | Type | Default | Description | -|:--------------------------|:---------|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-a`, `--all-tags` | `bool` | | Push all tags of an image to the repository | -| `--disable-content-trust` | `bool` | `true` | Skip image signing | -| `--platform` | `string` | | Push a platform-specific manifest as a single-platform image to the registry.
Image index won't be pushed, meaning that other manifests, including attestations won't be preserved.
'os[/arch[/variant]]': Explicit platform (eg. linux/amd64) | -| `-q`, `--quiet` | `bool` | | Suppress verbose output | +| Name | Type | Default | Description | +|:--------------------------|:---------|:--------|:--------------------------------------------------------------------------------------------------------------------------------------------| +| `-a`, `--all-tags` | `bool` | | Push all tags of an image to the repository | +| `--disable-content-trust` | `bool` | `true` | Skip image signing | +| `--platform` | `string` | | Push a platform-specific manifest as a single-platform image to the registry.
'os[/arch[/variant]]': Explicit platform (eg. linux/amd64) | +| `-q`, `--quiet` | `bool` | | Suppress verbose output | diff --git a/e2e/app/install_test.go b/e2e/app/install_test.go new file mode 100644 index 000000000000..65b0b4c71606 --- /dev/null +++ b/e2e/app/install_test.go @@ -0,0 +1,271 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/e2e/internal/fixtures" + "github.com/docker/cli/internal/test/output" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" + "gotest.tools/v3/fs" + "gotest.tools/v3/icmd" +) + +const defaultArgs = "DOCKER_APP_BASE DOCKER_APP_PATH VERSION HOSTARCH HOSTOS USERGID USERHOME USERID USERNAME" + +func TestInstallOne(t *testing.T) { + const buildCtx = "one" + const coolApp = "cool" + const coolScript = `#!/bin/sh + echo "Running 'cool' app $@ ..." + exit 0 + ` + dir := fs.NewDir(t, "test-install-single-file", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + ARG %s + COPY cool /egress/%s + CMD ["echo", "'cool' app successfully built!"] + `, fixtures.AlpineImage, defaultArgs, coolApp)), + fs.WithFile("cool", coolScript, fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base", + fs.WithDir("bin", + fs.WithMode(os.FileMode(0o755))), + ) + defer appBase.Remove() + installedLink := filepath.Join(appBase.Path(), "bin", coolApp) + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + result := icmd.RunCmd( + icmd.Command("docker", "app", "install", "--no-cache", "--launch", buildCtx, "--", "arg1", "arg2"), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + + // verify coolScript is installed/executed on host + output.Assert(t, result.Stdout(), map[int]func(string) error{ + 0: output.Prefix("Sending build context to Docker daemon"), + 13: output.Prefix("Successfully built "), + 14: output.Prefix("Image ID: "), + 15: output.Equals("'cool' app successfully built!"), + 16: output.Prefix("Container ID: "), + 17: output.Prefix("App copied to "), + 18: output.Prefix("App installed: "), + 19: output.Equals("Running 'cool' app arg1 arg2 ..."), + }) + installedScript, err := os.ReadFile(installedLink) + assert.NilError(t, err) + assert.Check(t, is.Equal(string(installedScript), coolScript)) +} + +func TestInstallMulti(t *testing.T) { + const buildCtx = "multi" + const runScript = `#!/bin/sh + echo "Running 'multi' $@ ..." + ## + ` + dir := fs.NewDir(t, "test-install-multi-file", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + ARG %s + COPY . /egress + CMD ["echo", "'multi' app successfully built!"] + `, fixtures.AlpineImage, defaultArgs)), + fs.WithFile(".dockerignore", ` + Dockerfile + .dockerignore + `, fs.WithMode(0o644)), + fs.WithFile("LICENSE", "", fs.WithMode(0o644)), + fs.WithFile("README.md", "", fs.WithMode(0o644)), + fs.WithFile("run", runScript, fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base", + fs.WithDir("bin", + fs.WithMode(os.FileMode(0o755))), + fs.WithDir("pkg", + fs.WithMode(os.FileMode(0o755))), + ) + defer appBase.Remove() + installedLink := filepath.Join(appBase.Path(), "bin", buildCtx) + installedApp := filepath.Join(appBase.Path(), "pkg", "file", dir.Path(), buildCtx) + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + result := icmd.RunCmd( + icmd.Command("docker", "app", "install", "--no-cache", "--launch", buildCtx, "--", "serve", "-p", "8080", "--arg1", "--arg2"), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + + // verify runScript is installed/executed on host + // and the symlink is created appropriately + output.Assert(t, result.Stdout(), map[int]func(string) error{ + 0: output.Prefix("Sending build context to Docker daemon"), + 13: output.Prefix("Successfully built "), + 14: output.Prefix("Image ID: "), + 15: output.Equals("'multi' app successfully built!"), + 16: output.Prefix("Container ID: "), + 17: output.Prefix("App copied to "), + 18: output.Prefix("App installed: "), + 19: output.Equals("Running 'multi' serve -p 8080 --arg1 --arg2 ..."), + }) + link, err := os.Readlink(installedLink) + assert.NilError(t, err) + runPath := filepath.Join(installedApp, "run") + assert.Check(t, is.Equal(link, runPath)) + installedRunScript, err := os.ReadFile(runPath) + assert.NilError(t, err) + assert.Check(t, is.Equal(string(installedRunScript), runScript)) + cnt, _ := countFiles(installedApp) + assert.Check(t, is.Equal(cnt, 3)) +} + +func TestInstallCustom(t *testing.T) { + const buildCtx = "custom" + + cwd, err := os.Getwd() + assert.NilError(t, err, "failed to get cwd for test") + + installScript := fmt.Sprintf(`#!/bin/sh + echo "Installing 'custom' app $@ ..." + echo "$DOCKER_APP_BASE" + cd '%s' && pwd + echo "$PATH" + echo "'Custom' app installed!" + `, filepath.Clean(cwd)) + + dir := fs.NewDir(t, "test-install-custom", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + ARG %s + COPY . /egress + CMD ["echo", "'custom' app successfully built!"] + `, fixtures.AlpineImage, defaultArgs)), + fs.WithFile(".dockerignore", ` + Dockerfile + .dockerignore + `, fs.WithMode(0o644)), + fs.WithFile("LICENSE", "", fs.WithMode(0o644)), + fs.WithFile("README.md", "", fs.WithMode(0o644)), + fs.WithFile("install", installScript, fs.WithMode(0o755)), + fs.WithFile("uninstall", "", fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base", + fs.WithDir("bin", + fs.WithMode(os.FileMode(0o755))), + fs.WithDir("pkg", + fs.WithMode(os.FileMode(0o755))), + ) + defer appBase.Remove() + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + result := icmd.RunCmd( + icmd.Command("docker", "app", "install", "--no-cache", buildCtx, "--", "--arg1", "--arg2"), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + + // verify installScript is executed on host + output.Assert(t, result.Stdout(), map[int]func(string) error{ + 0: output.Prefix("Sending build context to Docker daemon"), + 13: output.Prefix("Successfully built "), + 14: output.Prefix("Image ID: "), + 15: output.Equals("'custom' app successfully built!"), + 16: output.Prefix("Container ID: "), + 17: output.Prefix("App copied to "), + 18: output.Equals("Installing 'custom' app --arg1 --arg2 ..."), + 19: output.Equals(appBase.Path()), + 20: output.Equals(cwd), + 21: output.Equals(os.Getenv("PATH")), + 22: output.Equals("'Custom' app installed!"), + 23: output.Equals("App installer ran successfully"), + }) +} + +func TestInstallCustomDestination(t *testing.T) { + const buildCtx = "service" + + deployScript := `#!/bin/sh + echo "deploying 'service' $@ ..." + echo "'service' deployed!" + ` + + egress := "/pkg/releases/v1.0.0" + dir := fs.NewDir(t, "test-install-deploy", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + ARG %s + COPY . %s + CMD ["echo", "'service' successfully built!"] + `, fixtures.AlpineImage, defaultArgs, egress)), + fs.WithFile("config", "", fs.WithMode(0o644)), + fs.WithFile("install", deployScript, fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base") + defer appBase.Remove() + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + destBase := fs.NewDir(t, "custom-destination") + defer destBase.Remove() + dest := filepath.Join(destBase.Path(), "stage") + config := filepath.Join(dest, "config") + + result := icmd.RunCmd( + icmd.Command("docker", "app", "install", "--no-cache", "--destination", dest, "--egress", egress, buildCtx, "--", "arg1", "arg2"), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + + // verify deployScript is installed/executed on host + // and "config" file is copied to custom destination + output.Assert(t, result.Stdout(), map[int]func(string) error{ + 0: output.Prefix("Sending build context to Docker daemon"), + 13: output.Prefix("Successfully built "), + 14: output.Prefix("Image ID: "), + 15: output.Equals("'service' successfully built!"), + 16: output.Prefix("Container ID: "), + 17: output.Prefix("App copied to "), + 18: output.Equals("deploying 'service' arg1 arg2 ..."), + 19: output.Equals("'service' deployed!"), + }) + assert.Check(t, fileExists(config)) +} + +func withWorkingDir(dir *fs.Dir) func(*icmd.Cmd) { + return func(cmd *icmd.Cmd) { + cmd.Dir = dir.Path() + } +} + +func countFiles(dir string) (int, error) { + files, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + cnt := 0 + for _, file := range files { + if !file.IsDir() { + cnt++ + } + } + return cnt, nil +} diff --git a/e2e/app/launch_test.go b/e2e/app/launch_test.go new file mode 100644 index 000000000000..0ae810c905b7 --- /dev/null +++ b/e2e/app/launch_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/docker/cli/e2e/internal/fixtures" + "github.com/docker/cli/internal/test/output" + "gotest.tools/v3/assert" + "gotest.tools/v3/fs" + "gotest.tools/v3/icmd" +) + +func TestLaunchOne(t *testing.T) { + const buildCtx = "one" + const coolApp = "cool" + + cwd, err := os.Getwd() + assert.NilError(t, err, "failed to get cwd for test") + + testMsg := fmt.Sprintf("It is %v", time.Now()) + + coolScript := fmt.Sprintf(`#!/bin/sh + echo "Running 'cool' app ..." + cd '%s' && pwd + echo "$PATH" + echo "%s" + `, filepath.Clean(cwd), testMsg) + + dir := fs.NewDir(t, "test-launch-single-file", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + ARG %s + COPY cool /egress/%s + CMD ["echo", "'cool' app successfully built!"] + `, fixtures.AlpineImage, defaultArgs, coolApp)), + fs.WithFile("cool", coolScript, fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base", + fs.WithDir("bin", + fs.WithMode(os.FileMode(0o755))), + ) + defer appBase.Remove() + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + result := icmd.RunCmd( + icmd.Command("docker", "app", "launch", "--no-cache", buildCtx), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + + // verify coolScript is executed on host + // by comparing the cwd, PATH, and the random test message + output.Assert(t, result.Stdout(), map[int]func(string) error{ + 0: output.Prefix("Sending build context to Docker daemon"), + 18: output.Equals("Running 'cool' app ..."), + 19: output.Equals(cwd), + 20: output.Equals(os.Getenv("PATH")), + 21: output.Equals(testMsg), + }) +} + +func TestLaunchMulti(t *testing.T) { + const buildCtx = "multi" + + cwd, err := os.Getwd() + assert.NilError(t, err, "failed to get cwd for test") + + testMsg := fmt.Sprintf("It is %v", time.Now()) + + runScript := fmt.Sprintf(`#!/bin/sh + echo "Running 'multi' ..." + cd '%s' && pwd + echo "$PATH" + echo "%s" + ## + `, filepath.Clean(cwd), testMsg) + + dir := fs.NewDir(t, "test-launch-multi-file", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + ARG %s + COPY . /egress + CMD ["echo", "'multi' app successfully built!"] + `, fixtures.AlpineImage, defaultArgs)), + fs.WithFile(".dockerignore", ` + Dockerfile + .dockerignore + `, fs.WithMode(0o644)), + fs.WithFile("LICENSE", "", fs.WithMode(0o644)), + fs.WithFile("README.md", "", fs.WithMode(0o644)), + fs.WithFile("run", runScript, fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base", + fs.WithDir("bin", + fs.WithMode(os.FileMode(0o755))), + fs.WithDir("pkg", + fs.WithMode(os.FileMode(0o755))), + ) + defer appBase.Remove() + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + result := icmd.RunCmd( + icmd.Command("docker", "app", "launch", "--no-cache", buildCtx), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + + // verify runScript is executed on host + // by comparing the cwd, PATH, and the random test message + output.Assert(t, result.Stdout(), map[int]func(string) error{ + 0: output.Prefix("Sending build context to Docker daemon"), + 18: output.Equals("Running 'multi' ..."), + 19: output.Equals(cwd), + 20: output.Equals(os.Getenv("PATH")), + 21: output.Equals(testMsg), + }) +} diff --git a/e2e/app/main_test.go b/e2e/app/main_test.go new file mode 100644 index 000000000000..f31825080d0c --- /dev/null +++ b/e2e/app/main_test.go @@ -0,0 +1,17 @@ +package app + +import ( + "fmt" + "os" + "testing" + + "github.com/docker/cli/internal/test/environment" +) + +func TestMain(m *testing.M) { + if err := environment.Setup(); err != nil { + fmt.Println(err.Error()) + os.Exit(3) + } + os.Exit(m.Run()) +} diff --git a/e2e/app/remove_test.go b/e2e/app/remove_test.go new file mode 100644 index 000000000000..58f2917afd8c --- /dev/null +++ b/e2e/app/remove_test.go @@ -0,0 +1,205 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/e2e/internal/fixtures" + "gotest.tools/v3/assert" + "gotest.tools/v3/fs" + "gotest.tools/v3/icmd" +) + +func TestRemoveOne(t *testing.T) { + const buildCtx = "one" + const coolApp = "cool" + const coolScript = `#!/bin/sh + echo "Running 'cool' app ..." + uname -a + ` + dir := fs.NewDir(t, "test-install-single-file", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + COPY cool /egress/%s + CMD ["echo", "'cool' app successfully built!"] + `, fixtures.AlpineImage, coolApp)), + fs.WithFile("cool", coolScript, fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base", + fs.WithDir("bin", + fs.WithMode(os.FileMode(0o755))), + ) + defer appBase.Remove() + + installedLink := filepath.Join(appBase.Path(), "bin", coolApp) + installedApp := filepath.Join(appBase.Path(), "pkg", "file", dir.Path(), buildCtx) + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + result := icmd.RunCmd( + icmd.Command("docker", "app", "install", buildCtx), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + assert.Check(t, fileExists(installedLink)) + assert.Check(t, fileExists(installedApp)) + + result = icmd.RunCmd( + icmd.Command("docker", "app", "remove", buildCtx), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + assert.Check(t, !fileExists(installedLink)) + assert.Check(t, !fileExists(installedApp)) + assert.Check(t, !fileExists(filepath.Join(appBase.Path(), "pkg", "file"))) + assert.Check(t, fileExists(filepath.Join(appBase.Path(), "pkg"))) +} + +func TestRemoveMulti(t *testing.T) { + const buildCtx = "multi" + const runScript = `#!/bin/sh + echo "Running 'multi' ..." + uname -a + ## + ` + dir := fs.NewDir(t, "test-install-multi-file", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + COPY . /egress + CMD ["echo", "'multi' app successfully built!"] + `, fixtures.AlpineImage)), + fs.WithFile(".dockerignore", ` + Dockerfile + .dockerignore + `, fs.WithMode(0o644)), + fs.WithFile("LICENSE", "", fs.WithMode(0o644)), + fs.WithFile("README.md", "", fs.WithMode(0o644)), + fs.WithFile("run", runScript, fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base", + fs.WithDir("bin", + fs.WithMode(os.FileMode(0o755))), + fs.WithDir("pkg", + fs.WithMode(os.FileMode(0o755))), + ) + defer appBase.Remove() + + installedLink := filepath.Join(appBase.Path(), "bin", buildCtx) + installedApp := filepath.Join(appBase.Path(), "pkg", "file", dir.Path(), buildCtx) + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + result := icmd.RunCmd( + icmd.Command("docker", "app", "install", buildCtx), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + assert.Check(t, fileExists(installedLink)) + assert.Check(t, fileExists(installedApp)) + + result = icmd.RunCmd( + icmd.Command("docker", "app", "remove", buildCtx), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + assert.Check(t, !fileExists(installedLink)) + assert.Check(t, !fileExists(installedApp)) + assert.Check(t, !fileExists(filepath.Join(appBase.Path(), "pkg", "file"))) + assert.Check(t, fileExists(filepath.Join(appBase.Path(), "pkg"))) +} + +func TestRemoveCustom(t *testing.T) { + const buildCtx = "custom" + + cwd, err := os.Getwd() + assert.NilError(t, err, "failed to get cwd for test") + + // custom install/uninstall scripts will create/remove this + customDir := fs.NewDir(t, "custom") + customFile := filepath.Join(customDir.Path(), "custom.file") + + installScript := fmt.Sprintf(`#!/bin/sh + set -e + echo "Installing 'custom' app ..." + export PATH=/bin:/usr/bin + cd '%s' && pwd + CUSTOM_DIR='%s' + mkdir -p $CUSTOM_DIR + cd $CUSTOM_DIR && pwd + touch custom.file + echo "'Custom' app installed!" + `, filepath.Clean(cwd), customDir.Path()) + removeScript := fmt.Sprintf(`#!/bin/sh + set -e + echo "Removing 'custom' app ..." + export PATH=/bin:/usr/bin + cd '%s' && pwd + CUSTOM_DIR='%s' + rm -f $CUSTOM_DIR/custom.file + rmdir $CUSTOM_DIR + echo "'Custom' app removed!" +)`, filepath.Clean(cwd), customDir.Path()) + + dir := fs.NewDir(t, "test-install-custom", + fs.WithDir(buildCtx, + fs.WithFile("Dockerfile", fmt.Sprintf(` + FROM %s + COPY . /egress + CMD ["echo", "'custom' app successfully built!"] + `, fixtures.AlpineImage)), + fs.WithFile(".dockerignore", ` + Dockerfile + .dockerignore + `, fs.WithMode(0o644)), + fs.WithFile("LICENSE", "", fs.WithMode(0o644)), + fs.WithFile("README.md", "", fs.WithMode(0o644)), + fs.WithFile("install", installScript, fs.WithMode(0o755)), + fs.WithFile("uninstall", removeScript, fs.WithMode(0o755)), + ), + ) + defer dir.Remove() + + appBase := fs.NewDir(t, "docker-app-base", + fs.WithDir("bin", + fs.WithMode(os.FileMode(0o755))), + fs.WithDir("pkg", + fs.WithMode(os.FileMode(0o755))), + ) + defer appBase.Remove() + t.Setenv("DOCKER_APP_BASE", appBase.Path()) + + installedApp := filepath.Join(appBase.Path(), "pkg", "file", dir.Path(), buildCtx) + + result := icmd.RunCmd( + icmd.Command("docker", "app", "install", buildCtx), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + assert.Check(t, fileExists(installedApp)) + assert.Check(t, fileExists(customDir.Path())) + assert.Check(t, fileExists(customFile)) + + result = icmd.RunCmd( + icmd.Command("docker", "app", "remove", buildCtx), + withWorkingDir(dir), + ) + result.Assert(t, icmd.Success) + assert.Check(t, !fileExists(installedApp)) + assert.Check(t, !fileExists(customDir.Path())) + assert.Check(t, !fileExists(customFile)) + assert.Check(t, !fileExists(filepath.Join(appBase.Path(), "pkg", "file"))) + assert.Check(t, fileExists(filepath.Join(appBase.Path(), "pkg"))) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/e2e/container/run_test.go b/e2e/container/run_test.go index d4fea8176901..fa8ea72b0334 100644 --- a/e2e/container/run_test.go +++ b/e2e/container/run_test.go @@ -1,10 +1,8 @@ package container import ( - "bytes" "fmt" "strings" - "syscall" "testing" "time" @@ -15,7 +13,6 @@ import ( is "gotest.tools/v3/assert/cmp" "gotest.tools/v3/golden" "gotest.tools/v3/icmd" - "gotest.tools/v3/poll" "gotest.tools/v3/skip" ) @@ -224,26 +221,3 @@ func TestMountSubvolume(t *testing.T) { }) } } - -func TestProcessTermination(t *testing.T) { - var out bytes.Buffer - cmd := icmd.Command("docker", "run", "--rm", "-i", fixtures.AlpineImage, - "sh", "-c", "echo 'starting trap'; trap 'echo got signal; exit 0;' TERM; while true; do sleep 10; done") - cmd.Stdout = &out - cmd.Stderr = &out - - result := icmd.StartCmd(cmd).Assert(t, icmd.Success) - - poll.WaitOn(t, func(t poll.LogT) poll.Result { - if strings.Contains(result.Stdout(), "starting trap") { - return poll.Success() - } - return poll.Continue("waiting for process to trap signal") - }, poll.WithDelay(1*time.Second), poll.WithTimeout(5*time.Second)) - - assert.NilError(t, result.Cmd.Process.Signal(syscall.SIGTERM)) - - icmd.WaitOnCmd(time.Second*10, result).Assert(t, icmd.Expected{ - ExitCode: 0, - }) -}