Add branch/tag name to the app path (URL fragment) for multiple version support

Support force install if app has already been installed
Add default args/envs
Support VERSION build arg

Signed-off-by: Qiang Li <liqiang@gmail.com>
This commit is contained in:
Qiang Li 2024-07-08 19:15:22 -07:00
parent 01db39ab1b
commit a696827ba7
14 changed files with 460 additions and 265 deletions

View File

@ -2,16 +2,20 @@ package app
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/user"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"time" "time"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/container" "github.com/docker/cli/cli/command/container"
"github.com/docker/cli/cli/command/image" "github.com/docker/cli/cli/command/image"
"github.com/docker/docker/errdefs"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
) )
@ -58,6 +62,8 @@ func addInstallFlags(flags *pflag.FlagSet, dest string, trust bool) *AppOptions
flags.StringVar(&options.destination, "destination", dest, "Set local host path for app") flags.StringVar(&options.destination, "destination", dest, "Set local host path for app")
flags.BoolVar(&options.launch, "launch", false, "Start app after installation") 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.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 // build/run flags
flags.StringVar(&options.imageIDFile, "iidfile", imageIDFile, "Write the image ID to the file") flags.StringVar(&options.imageIDFile, "iidfile", imageIDFile, "Write the image ID to the file")
@ -109,18 +115,45 @@ func addInstallFlags(flags *pflag.FlagSet, dest string, trust bool) *AppOptions
// `docker cp` flags // `docker cp` flags
include(flags, eflags, []string{"archive", "follow-link"}) include(flags, eflags, []string{"archive", "follow-link"})
// install specific build args
addBuildArgs(flags)
return options return options
} }
func addBuildArgs(flags *pflag.FlagSet) { func setBuildArgs(options *AppOptions) error {
flag := flags.Lookup("build-arg") bopts := options.buildOpts
if flag != nil { if bopts == nil {
flag.Value.Set("HOSTOS=" + runtime.GOOS) return errors.New("build options not set")
flag.Value.Set("HOSTARCH=" + runtime.GOARCH)
} }
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 { func installApp(ctx context.Context, adapter cliAdapter, flags *pflag.FlagSet, options *AppOptions) error {
@ -133,7 +166,7 @@ func installApp(ctx context.Context, adapter cliAdapter, flags *pflag.FlagSet, o
return err return err
} }
bin, err := runPostInstall(adapter, dir, options) bin, err := runPostInstall(ctx, adapter, dir, options)
if err != nil { if err != nil {
return err return err
} }
@ -146,8 +179,25 @@ func installApp(ctx context.Context, adapter cliAdapter, flags *pflag.FlagSet, o
return nil 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 // runInstall calls the build, run, and cp commands
func runInstall(ctx context.Context, dockerCli cliAdapter, flags *pflag.FlagSet, options *AppOptions) (string, error) { 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) iid, err := buildImage(ctx, dockerCli, options)
if err != nil { if err != nil {
return "", err return "", err
@ -218,22 +268,51 @@ func copyFiles(ctx context.Context, dockerCli cliAdapter, cid string, options *A
return filepath.Join(dir, filepath.Base(options.egress)), nil return filepath.Join(dir, filepath.Base(options.egress)), nil
} }
func runPostInstall(dockerCli cliAdapter, dir string, options *AppOptions) (string, error) { 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() { if !options.isDockerAppBase() {
return "", installCustom(dir, options.destination, options) return "", installCustom(dir, options.destination, options)
} }
// for the default destination 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 there is only one file, create symlink for the file
if fp, err := oneChild(dir); err == nil && fp != "" { if fp, err := oneChild(dir); err == nil && fp != "" {
binPath := options.binPath() appName := options.name
appPath, err := options.appPath() if appName == "" {
if err != nil { appName = makeAppName(fp)
}
if err := validateName(appName); err != nil {
return "", err return "", err
} }
link, err := installOne(dir, fp, binPath, appPath) link, err := installOne(appName, dir, fp, binPath, appPath)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -243,13 +322,15 @@ func runPostInstall(dockerCli cliAdapter, dir string, options *AppOptions) (stri
// if there is a run file, create symlink for the run file. // if there is a run file, create symlink for the run file.
if fp, err := locateFile(dir, runnerName); err == nil && fp != "" { if fp, err := locateFile(dir, runnerName); err == nil && fp != "" {
binPath := options.binPath() appName := options.name
appPath, err := options.appPath() if appName == "" {
if err != nil { appName = makeAppName(appPath)
}
if err := validateName(appName); err != nil {
return "", err return "", err
} }
link, err := installRunFile(dir, fp, binPath, appPath) link, err := installRunFile(appName, dir, fp, binPath, appPath)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -258,10 +339,6 @@ func runPostInstall(dockerCli cliAdapter, dir string, options *AppOptions) (stri
} }
// custom install // custom install
appPath, err := options.appPath()
if err != nil {
return "", err
}
if err := installCustom(dir, appPath, options); err != nil { if err := installCustom(dir, appPath, options); err != nil {
return "", err return "", err
} }
@ -270,38 +347,39 @@ func runPostInstall(dockerCli cliAdapter, dir string, options *AppOptions) (stri
return "", nil return "", nil
} }
// installOne creates a symlink to the only file in appPath // removeApp removes the existing app
func installOne(egress, runPath, binPath, appPath string) (string, error) { func removeApp(dockerCli cliAdapter, binPath, appPath string, options *AppOptions) error {
appName := filepath.Base(runPath) envs, _ := options.makeEnvs()
if err := validateName(appName); err != nil { runUninstaller(dockerCli, appPath, envs)
return "", err
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) link := filepath.Join(binPath, appName)
target := filepath.Join(appPath, appName) target := filepath.Join(appPath, appName)
return install(link, target, egress, binPath, appPath) return install(link, target, egress, appPath)
} }
// installRunFile creates a symlink to the run file in appPath // installRunFile creates a symlink to the run file in appPath
// use the base name as the app name func installRunFile(appName, egress, runPath, binPath, appPath string) (string, error) {
func installRunFile(egress, runPath, binPath, appPath string) (string, error) {
appName := filepath.Base(appPath)
if err := validateName(appName); err != nil {
return "", err
}
link := filepath.Join(binPath, appName) link := filepath.Join(binPath, appName)
target := filepath.Join(appPath, filepath.Base(runPath)) target := filepath.Join(appPath, filepath.Base(runPath))
return install(link, target, egress, binPath, appPath) return install(link, target, egress, appPath)
} }
// instal creates a symlink to the target file // instal creates a symlink to the target file
func install(link, target, egress, binPath, appPath string) (string, error) { func install(link, target, egress, appPath string) (string, error) {
if _, err := os.Stat(appPath); err == nil { if ok, err := isSymlinkOK(link, target); err != nil {
return "", fmt.Errorf("app package exists: %s! Try again after removing it", appPath)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("installation failed: %w", err)
}
if ok, err := isSymlinkToOK(link, target); err != nil {
return "", err return "", err
} else { } else {
if !ok { if !ok {
@ -314,10 +392,6 @@ func install(link, target, egress, binPath, appPath string) (string, error) {
} }
} }
if err := os.MkdirAll(binPath, 0o755); err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(appPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(appPath), 0o755); err != nil {
return "", err return "", err
} }
@ -337,18 +411,6 @@ func install(link, target, egress, binPath, appPath string) (string, error) {
} }
func installCustom(dir string, appPath string, options *AppOptions) error { func installCustom(dir string, appPath string, options *AppOptions) error {
exist := func(p string) bool {
_, err := os.Stat(p)
if os.IsNotExist(err) {
return false
}
return err == nil
}
if exist(appPath) {
return fmt.Errorf("destination exists: %s", appPath)
}
if err := os.MkdirAll(filepath.Dir(appPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(appPath), 0o755); err != nil {
return err return err
} }
@ -358,7 +420,7 @@ func installCustom(dir string, appPath string, options *AppOptions) error {
// optionally run the installer if it exists // optionally run the installer if it exists
installer := filepath.Join(appPath, installerName) installer := filepath.Join(appPath, installerName)
if !exist(installer) { if !fileExist(installer) {
return nil return nil
} }
if err := os.Chmod(installer, 0o755); err != nil { if err := os.Chmod(installer, 0o755); err != nil {
@ -367,3 +429,17 @@ func installCustom(dir string, appPath string, options *AppOptions) error {
return launch(installer, options) 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
}

View File

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
@ -64,7 +63,11 @@ func runLaunch(dir string, options *AppOptions) error {
if fp, err := oneChild(dir); err == nil && fp != "" { if fp, err := oneChild(dir); err == nil && fp != "" {
return fp, nil return fp, nil
} }
if fp, err := locateFile(dir, runnerName); err == nil && fp != "" { appName := options.name
if appName == "" {
appName = runnerName
}
if fp, err := locateFile(dir, appName); err == nil && fp != "" {
return fp, nil return fp, nil
} }
return "", errors.New("no app file found") return "", errors.New("no app file found")
@ -80,15 +83,9 @@ func runLaunch(dir string, options *AppOptions) error {
// launch copies the current environment and set DOCKER_APP_BASE before spawning the app // launch copies the current environment and set DOCKER_APP_BASE before spawning the app
func launch(app string, options *AppOptions) error { func launch(app string, options *AppOptions) error {
envs := make(map[string]string) envs, err := options.makeEnvs()
if err != nil {
// copy the current environment return err
for _, v := range os.Environ() {
kv := strings.SplitN(v, "=", 2)
envs[kv[0]] = kv[1]
} }
envs["DOCKER_APP_BASE"] = options._appBase
return spawn(app, options.launchArgs(), envs, options.detach) return spawn(app, options.launchArgs(), envs, options.detach)
} }

View File

@ -4,8 +4,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/user"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -14,7 +16,7 @@ import (
"github.com/docker/cli/cli/command/image" "github.com/docker/cli/cli/command/image"
) )
// runnerName is the executable name for starting the app // runnerName is the default executable app name for starting the app
const runnerName = "run" const runnerName = "run"
// installerName is the executable name for custom installation // installerName is the executable name for custom installation
@ -23,7 +25,7 @@ const installerName = "install"
// uninstallerName is the executable name for custom installation // uninstallerName is the executable name for custom installation
const uninstallerName = "uninstall" const uninstallerName = "uninstall"
// namePattern is for validating egress and app name // namePattern is for validating app name
const namePattern = "^[a-zA-Z0-9][a-zA-Z0-9_.+-]+$" const namePattern = "^[a-zA-Z0-9][a-zA-Z0-9_.+-]+$"
var nameRegexp = regexp.MustCompile(namePattern) var nameRegexp = regexp.MustCompile(namePattern)
@ -35,12 +37,43 @@ func validateName(s string) error {
return nil 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 // defaultAppBase is docker app's base location specified by
// DOCKER_APP_BASE environment variable defaulted to ~/.docker-app/ // DOCKER_APP_BASE environment variable defaulted to ~/.docker/app/
func defaultAppBase() string { func defaultAppBase() string {
if base := os.Getenv("DOCKER_APP_BASE"); base != "" { if base := os.Getenv("DOCKER_APP_BASE"); base != "" {
return filepath.Clean(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 // default ~/.docker/app
// ignore error and use the current working directory // ignore error and use the current working directory
// if home directory is not available // if home directory is not available
@ -48,8 +81,108 @@ func defaultAppBase() string {
return filepath.Join(home, ".docker", "app") 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 // AppOptions holds the options for the `app` subcommands
type AppOptions struct { type AppOptions struct {
commonOptions
// flags for install // flags for install
// path on local host // path on local host
@ -64,6 +197,12 @@ type AppOptions struct {
// start exported app // start exported app
launch bool launch bool
// overwrite existing app
force bool
// app name
name string
// the following are existing flags // the following are existing flags
// build flags // build flags
@ -82,25 +221,6 @@ type AppOptions struct {
runOpts *container.RunOptions runOpts *container.RunOptions
containerOpts *container.ContainerOptions containerOpts *container.ContainerOptions
copyOpts *container.CopyOptions copyOpts *container.CopyOptions
// runtime
// command line args
_args []string
// docker app base location, fixed once set
_appBase string
}
func (o *AppOptions) setArgs(args []string) {
o._args = args
}
// buildContext returns the build context for building image
func (o *AppOptions) buildContext() string {
if len(o._args) == 0 {
return "."
}
return o._args[0]
} }
// runArgs returns the command line args for running the container // runArgs returns the command line args for running the container
@ -128,19 +248,6 @@ func (o *AppOptions) isDockerAppBase() bool {
return strings.HasPrefix(s, o._appBase) return strings.HasPrefix(s, o._appBase)
} }
// binPath returns the bin directory under the default app base
func (o *AppOptions) binPath() string {
return filepath.Join(o._appBase, "bin")
}
// appPath returns the app directory under the default app base
func (o *AppOptions) appPath() (string, error) {
if len(o._args) == 0 {
return "", errors.New("missing args")
}
return makeAppPath(o._appBase, o._args[0])
}
// cacheDir returns a temp cache directory under the default app base // cacheDir returns a temp cache directory under the default app base
// appBase is chosen as the parent directory to avoid issues such as: // appBase is chosen as the parent directory to avoid issues such as:
// permission, disk space, renaming across partitions. // permission, disk space, renaming across partitions.
@ -170,7 +277,9 @@ func (o *AppOptions) containerID() (string, error) {
func newAppOptions() *AppOptions { func newAppOptions() *AppOptions {
return &AppOptions{ return &AppOptions{
_appBase: defaultAppBase(), commonOptions: commonOptions{
_appBase: defaultAppBase(),
},
} }
} }
@ -182,53 +291,17 @@ func validateAppOptions(options *AppOptions) error {
return errors.New("egress is required") return errors.New("egress is required")
} }
name := filepath.Base(options.egress)
if err := validateName(name); err != nil {
return fmt.Errorf("invalid egress path: %s %v", options.egress, err)
}
return nil return nil
} }
type removeOptions struct { type removeOptions struct {
_appBase string commonOptions
}
// makeAppPath returns the app directory under the default app base
// appBase/pkg/scheme/host/path
func (o *removeOptions) makeAppPath(s string) (string, error) {
return makeAppPath(o._appBase, s)
}
// binPath returns the bin directory under the default app base
func (o *removeOptions) binPath() string {
return filepath.Join(o._appBase, "bin")
}
// pkgPath returns the pkg directory under the default app base
func (o *removeOptions) pkgPath() string {
return filepath.Join(o._appBase, "pkg")
} }
func newRemoveOptions() *removeOptions { func newRemoveOptions() *removeOptions {
return &removeOptions{ return &removeOptions{
_appBase: defaultAppBase(), commonOptions: commonOptions{
_appBase: defaultAppBase(),
},
} }
} }
// makeAppPath builds the default app path
// in the format: appBase/pkg/scheme/host/path
func makeAppPath(appBase, s string) (string, error) {
u, err := parseURL(s)
if err != nil {
return "", err
}
if u.Path == "" {
return "", fmt.Errorf("missing path: %v", u)
}
name := filepath.Base(u.Path)
if err := validateName(name); err != nil {
return "", fmt.Errorf("invalid path: %s %v", u.Path, err)
}
return filepath.Join(appBase, "pkg", u.Scheme, u.Host, u.Path), nil
}

View File

@ -39,35 +39,24 @@ func NewRemoveCommand(dockerCli command.Cli) *cobra.Command {
// remove symlinks of the app under bin path // remove symlinks of the app under bin path
func runRemove(dockerCli command.Cli, apps []string, options *removeOptions) error { func runRemove(dockerCli command.Cli, apps []string, options *removeOptions) error {
binPath := options.binPath() binPath := options.binPath()
targets, err := findSymlinks(binPath)
// find all symlinks in binPath to remove later if err != nil {
// if they point to the app package return err
targets := make(map[string]string)
if links, err := findLinks(binPath); err == nil {
for _, link := range links {
if target, err := os.Readlink(link); err == nil {
targets[target] = link
}
}
} }
var failed []string var failed []string
for _, app := range apps { for _, app := range apps {
appPath, err := options.makeAppPath(app) options.setArgs([]string{app})
appPath, err := options.appPath()
if err != nil { if err != nil {
failed = append(failed, app) failed = append(failed, app)
continue continue
} }
// optionally run uninstall if provided // optionally run uninstall if provided
uninstaller := filepath.Join(appPath, uninstallerName) envs, _ := options.makeEnvs()
if _, err := os.Stat(uninstaller); err == nil { runUninstaller(dockerCli, appPath, envs)
err := spawn(uninstaller, nil, nil, false)
if err != nil {
fmt.Fprintf(dockerCli.Err(), "%s failed to run: %v\n", uninstaller, err)
}
}
// remove all files under the app path // remove all files under the app path
if err := os.RemoveAll(appPath); err != nil { if err := os.RemoveAll(appPath); err != nil {
@ -77,16 +66,7 @@ func runRemove(dockerCli command.Cli, apps []string, options *removeOptions) err
removeEmptyPath(options.pkgPath(), appPath) removeEmptyPath(options.pkgPath(), appPath)
fmt.Fprintf(dockerCli.Out(), "app package removed %s\n", appPath) fmt.Fprintf(dockerCli.Out(), "app package removed %s\n", appPath)
// remove symlinks of the app if any cleanupSymlink(dockerCli, appPath, targets)
for target, link := range targets {
if strings.Contains(target, appPath) {
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)
}
}
}
} }
if len(failed) > 0 { if len(failed) > 0 {
@ -94,3 +74,59 @@ func runRemove(dockerCli command.Cli, apps []string, options *removeOptions) err
} }
return nil 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)
}
}
}
}

View File

@ -41,8 +41,10 @@ func TestRunRemove(t *testing.T) {
createApp := func(name string, args []string) ([]string, error) { createApp := func(name string, args []string) ([]string, error) {
o := &AppOptions{ o := &AppOptions{
_appBase: appBase, commonOptions: commonOptions{
_args: args, _appBase: appBase,
_args: args,
},
} }
appPath, err := o.appPath() appPath, err := o.appPath()
if err != nil { if err != nil {
@ -77,7 +79,7 @@ func TestRunRemove(t *testing.T) {
expectErr: "", expectErr: "",
}, },
{ {
name: "a few apps", args: []string{"example.com/org/one", "example.com/org/two", "example.com/org/three"}, 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 { fakeInstall: func(args []string) []string {
var files []string var files []string
for _, a := range args { for _, a := range args {

View File

@ -11,8 +11,7 @@ import (
"syscall" "syscall"
) )
// spawn runs the specified command and returns the PID // spawn runs the specified command
// of the spawned process
func spawn(bin string, args []string, envMap map[string]string, detach bool) error { func spawn(bin string, args []string, envMap map[string]string, detach bool) error {
toEnv := func() []string { toEnv := func() []string {
var env []string var env []string
@ -138,10 +137,12 @@ func parseURL(s string) (*url.URL, error) {
} }
} }
// isSymlinkToOK checks if it is ok to create a symlink to the target // isSymlinkOK checks if it is ok to create a symlink to the target
// it is ok if the path does not exist // it is ok if the path does not exist
// or if the path is a symlink that points to this same target // or if the path is a symlink that points to the same target.
func isSymlinkToOK(path, target string) (bool, error) { // 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) fi, err := os.Lstat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@ -158,7 +159,14 @@ func isSymlinkToOK(path, target string) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
return link == target, nil
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 // splitAtDashDash splits a string array into two parts
@ -219,3 +227,47 @@ func removeEmptyPath(root, dir string) error {
return rm(filepath.Dir(dir)) 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)
}

View File

@ -189,9 +189,6 @@ func runContainer(ctx context.Context, dockerCli command.Cli, runOpts *RunOption
detachKeys = runOpts.detachKeys 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{ closeFn, err := attachContainer(ctx, dockerCli, containerID, &errCh, config, container.AttachOptions{
Stream: true, Stream: true,
Stdin: config.AttachStdin, Stdin: config.AttachStdin,

View File

@ -90,6 +90,10 @@ func (o *BuildOptions) SetImageIDFile(imageIDFile string) {
o.imageIDFile = imageIDFile o.imageIDFile = imageIDFile
} }
func (o *BuildOptions) SetBuildArg(value string) {
o.buildArgs.Set(value)
}
func newBuildOptions() BuildOptions { func newBuildOptions() BuildOptions {
ulimits := make(map[string]*container.Ulimit) ulimits := make(map[string]*container.Ulimit)
return BuildOptions{ return BuildOptions{

View File

@ -8,7 +8,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"strings" "os"
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/distribution/reference" "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.all, "all-tags", "a", false, "Push all tags of an image to the repository")
flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress verbose output") flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress verbose output")
command.AddTrustSigningFlags(flags, &opts.untrusted, dockerCli.ContentTrustEnabled()) command.AddTrustSigningFlags(flags, &opts.untrusted, dockerCli.ContentTrustEnabled())
flags.StringVar(&opts.platform, "platform", os.Getenv("DOCKER_DEFAULT_PLATFORM"),
// 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", "",
`Push a platform-specific manifest as a single-platform image to the registry. `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)`) 'os[/arch[/variant]]': Explicit platform (eg. linux/amd64)`)
flags.SetAnnotation("platform", "version", []string{"1.46"}) flags.SetAnnotation("platform", "version", []string{"1.46"})
@ -84,9 +79,9 @@ func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error
} }
platform = &p platform = &p
printNote(dockerCli, `Using --platform pushes only the specified platform manifest of a multi-platform image index. printNote(dockerCli, `Selecting a single platform will only push one matching image manifest from a multi-platform image index.
Other components, like attestations, will not be included. This means that any other components attached to the multi-platform image index (like Buildkit attestations) won't be pushed.
To push the complete multi-platform image, remove the --platform flag. 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) { func printNote(dockerCli command.Cli, format string, args ...any) {
if dockerCli.Err().IsTerminal() { if dockerCli.Err().IsTerminal() {
format = strings.ReplaceAll(format, "--platform", aec.Bold.Apply("--platform")) _, _ = fmt.Fprint(dockerCli.Err(), aec.WhiteF.Apply(aec.CyanB.Apply("[ NOTE ]"))+" ")
} } else {
_, _ = fmt.Fprint(dockerCli.Err(), "[ NOTE ] ")
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.Fprintf(dockerCli.Err(), aec.Bold.Apply(format)+"\n", args...)
} }

View File

@ -9,12 +9,12 @@ Upload an image to a registry
### Options ### Options
| Name | Type | Default | Description | | Name | Type | Default | Description |
|:---------------------------------------------|:---------|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |:---------------------------------------------|:---------|:--------|:--------------------------------------------------------------------------------------------------------------------------------------------|
| [`-a`](#all-tags), [`--all-tags`](#all-tags) | `bool` | | Push all tags of an image to the repository | | [`-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 | | `--disable-content-trust` | `bool` | `true` | Skip image signing |
| `--platform` | `string` | | Push a platform-specific manifest as a single-platform image to the registry.<br>Image index won't be pushed, meaning that other manifests, including attestations won't be preserved.<br>'os[/arch[/variant]]': Explicit platform (eg. linux/amd64) | | `--platform` | `string` | | Push a platform-specific manifest as a single-platform image to the registry.<br>'os[/arch[/variant]]': Explicit platform (eg. linux/amd64) |
| `-q`, `--quiet` | `bool` | | Suppress verbose output | | `-q`, `--quiet` | `bool` | | Suppress verbose output |
<!---MARKER_GEN_END--> <!---MARKER_GEN_END-->

View File

@ -9,12 +9,12 @@ Upload an image to a registry
### Options ### Options
| Name | Type | Default | Description | | Name | Type | Default | Description |
|:--------------------------|:---------|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |:--------------------------|:---------|:--------|:--------------------------------------------------------------------------------------------------------------------------------------------|
| `-a`, `--all-tags` | `bool` | | Push all tags of an image to the repository | | `-a`, `--all-tags` | `bool` | | Push all tags of an image to the repository |
| `--disable-content-trust` | `bool` | `true` | Skip image signing | | `--disable-content-trust` | `bool` | `true` | Skip image signing |
| `--platform` | `string` | | Push a platform-specific manifest as a single-platform image to the registry.<br>Image index won't be pushed, meaning that other manifests, including attestations won't be preserved.<br>'os[/arch[/variant]]': Explicit platform (eg. linux/amd64) | | `--platform` | `string` | | Push a platform-specific manifest as a single-platform image to the registry.<br>'os[/arch[/variant]]': Explicit platform (eg. linux/amd64) |
| `-q`, `--quiet` | `bool` | | Suppress verbose output | | `-q`, `--quiet` | `bool` | | Suppress verbose output |
<!---MARKER_GEN_END--> <!---MARKER_GEN_END-->

View File

@ -14,6 +14,8 @@ import (
"gotest.tools/v3/icmd" "gotest.tools/v3/icmd"
) )
const defaultArgs = "DOCKER_APP_BASE DOCKER_APP_PATH VERSION HOSTARCH HOSTOS USERGID USERHOME USERID USERNAME"
func TestInstallOne(t *testing.T) { func TestInstallOne(t *testing.T) {
const buildCtx = "one" const buildCtx = "one"
const coolApp = "cool" const coolApp = "cool"
@ -25,10 +27,10 @@ func TestInstallOne(t *testing.T) {
fs.WithDir(buildCtx, fs.WithDir(buildCtx,
fs.WithFile("Dockerfile", fmt.Sprintf(` fs.WithFile("Dockerfile", fmt.Sprintf(`
FROM %s FROM %s
ARG HOSTOS HOSTARCH ARG %s
COPY cool /egress/%s COPY cool /egress/%s
CMD ["echo", "'cool' app successfully built!"] CMD ["echo", "'cool' app successfully built!"]
`, fixtures.AlpineImage, coolApp)), `, fixtures.AlpineImage, defaultArgs, coolApp)),
fs.WithFile("cool", coolScript, fs.WithMode(0o755)), fs.WithFile("cool", coolScript, fs.WithMode(0o755)),
), ),
) )
@ -74,10 +76,10 @@ func TestInstallMulti(t *testing.T) {
fs.WithDir(buildCtx, fs.WithDir(buildCtx,
fs.WithFile("Dockerfile", fmt.Sprintf(` fs.WithFile("Dockerfile", fmt.Sprintf(`
FROM %s FROM %s
ARG HOSTOS HOSTARCH ARG %s
COPY . /egress COPY . /egress
CMD ["echo", "'multi' app successfully built!"] CMD ["echo", "'multi' app successfully built!"]
`, fixtures.AlpineImage)), `, fixtures.AlpineImage, defaultArgs)),
fs.WithFile(".dockerignore", ` fs.WithFile(".dockerignore", `
Dockerfile Dockerfile
.dockerignore .dockerignore
@ -147,10 +149,10 @@ func TestInstallCustom(t *testing.T) {
fs.WithDir(buildCtx, fs.WithDir(buildCtx,
fs.WithFile("Dockerfile", fmt.Sprintf(` fs.WithFile("Dockerfile", fmt.Sprintf(`
FROM %s FROM %s
ARG HOSTOS HOSTARCH ARG %s
COPY . /egress COPY . /egress
CMD ["echo", "'custom' app successfully built!"] CMD ["echo", "'custom' app successfully built!"]
`, fixtures.AlpineImage)), `, fixtures.AlpineImage, defaultArgs)),
fs.WithFile(".dockerignore", ` fs.WithFile(".dockerignore", `
Dockerfile Dockerfile
.dockerignore .dockerignore
@ -208,10 +210,10 @@ func TestInstallCustomDestination(t *testing.T) {
fs.WithDir(buildCtx, fs.WithDir(buildCtx,
fs.WithFile("Dockerfile", fmt.Sprintf(` fs.WithFile("Dockerfile", fmt.Sprintf(`
FROM %s FROM %s
ARG HOSTOS HOSTARCH ARG %s
COPY . %s COPY . %s
CMD ["echo", "'service' successfully built!"] CMD ["echo", "'service' successfully built!"]
`, fixtures.AlpineImage, egress)), `, fixtures.AlpineImage, defaultArgs, egress)),
fs.WithFile("config", "", fs.WithMode(0o644)), fs.WithFile("config", "", fs.WithMode(0o644)),
fs.WithFile("install", deployScript, fs.WithMode(0o755)), fs.WithFile("install", deployScript, fs.WithMode(0o755)),
), ),

View File

@ -34,10 +34,10 @@ func TestLaunchOne(t *testing.T) {
fs.WithDir(buildCtx, fs.WithDir(buildCtx,
fs.WithFile("Dockerfile", fmt.Sprintf(` fs.WithFile("Dockerfile", fmt.Sprintf(`
FROM %s FROM %s
ARG HOSTOS HOSTARCH ARG %s
COPY cool /egress/%s COPY cool /egress/%s
CMD ["echo", "'cool' app successfully built!"] CMD ["echo", "'cool' app successfully built!"]
`, fixtures.AlpineImage, coolApp)), `, fixtures.AlpineImage, defaultArgs, coolApp)),
fs.WithFile("cool", coolScript, fs.WithMode(0o755)), fs.WithFile("cool", coolScript, fs.WithMode(0o755)),
), ),
) )
@ -87,10 +87,10 @@ func TestLaunchMulti(t *testing.T) {
fs.WithDir(buildCtx, fs.WithDir(buildCtx,
fs.WithFile("Dockerfile", fmt.Sprintf(` fs.WithFile("Dockerfile", fmt.Sprintf(`
FROM %s FROM %s
ARG HOSTOS HOSTARCH ARG %s
COPY . /egress COPY . /egress
CMD ["echo", "'multi' app successfully built!"] CMD ["echo", "'multi' app successfully built!"]
`, fixtures.AlpineImage)), `, fixtures.AlpineImage, defaultArgs)),
fs.WithFile(".dockerignore", ` fs.WithFile(".dockerignore", `
Dockerfile Dockerfile
.dockerignore .dockerignore

View File

@ -1,10 +1,8 @@
package container package container
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
"syscall"
"testing" "testing"
"time" "time"
@ -15,7 +13,6 @@ import (
is "gotest.tools/v3/assert/cmp" is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/golden" "gotest.tools/v3/golden"
"gotest.tools/v3/icmd" "gotest.tools/v3/icmd"
"gotest.tools/v3/poll"
"gotest.tools/v3/skip" "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,
})
}