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,
- })
-}