diff --git a/cmd/root.go b/cmd/root.go index a350410..daa9437 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -39,7 +39,6 @@ var ( lifecycleHooks bool rollingRestart bool scope string - // Set on build using ldflags ) var rootCmd = NewRootCommand() @@ -75,6 +74,7 @@ func Execute() { // PreRun is a lifecycle hook that runs before the command is executed. func PreRun(cmd *cobra.Command, _ []string) { f := cmd.PersistentFlags() + flags.ProcessFlagAliases(f) if enabled, _ := f.GetBool("no-color"); enabled { log.SetFormatter(&log.TextFormatter{ @@ -94,18 +94,7 @@ func PreRun(cmd *cobra.Command, _ []string) { log.SetLevel(log.TraceLevel) } - pollingSet := f.Changed("interval") - schedule, _ := f.GetString("schedule") - cronLen := len(schedule) - - if pollingSet && cronLen > 0 { - log.Fatal("Only schedule or interval can be defined, not both.") - } else if cronLen > 0 { - scheduleSpec, _ = f.GetString("schedule") - } else { - interval, _ := f.GetInt("interval") - scheduleSpec = "@every " + strconv.Itoa(interval) + "s" - } + scheduleSpec, _ = f.GetString("schedule") flags.GetSecretsFromFiles(cmd) cleanup, noRestart, monitorOnly, timeout = flags.ReadFlags(cmd) @@ -119,7 +108,9 @@ func PreRun(cmd *cobra.Command, _ []string) { rollingRestart, _ = f.GetBool("rolling-restart") scope, _ = f.GetString("scope") - log.Debug(scope) + if scope != "" { + log.Debugf(`Using scope %q`, scope) + } // configure environment vars for client err := flags.EnvConfig(cmd) diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 531d5d2..bae02a8 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -3,6 +3,7 @@ package flags import ( "bufio" "errors" + "fmt" "io/ioutil" "os" "strings" @@ -153,22 +154,32 @@ func RegisterSystemFlags(rootCmd *cobra.Command) { "", viper.GetString("WATCHTOWER_HTTP_API_TOKEN"), "Sets an authentication token to HTTP API requests.") + flags.BoolP( "http-api-periodic-polls", "", viper.GetBool("WATCHTOWER_HTTP_API_PERIODIC_POLLS"), "Also run periodic updates (specified with --interval and --schedule) if HTTP API is enabled") + // https://no-color.org/ flags.BoolP( "no-color", "", viper.IsSet("NO_COLOR"), "Disable ANSI color escape codes in log output") + flags.StringP( "scope", "", viper.GetString("WATCHTOWER_SCOPE"), "Defines a monitoring scope for the Watchtower instance.") + + flags.StringP( + "porcelain", + "P", + viper.GetString("WATCHTOWER_PORCELAIN"), + `Write session results to stdout using a stable versioned format. Supported values: "v1"`) + } // RegisterNotificationFlags that are used by watchtower to send notifications @@ -343,6 +354,10 @@ Should only be used for testing.`) viper.GetString("WATCHTOWER_WARN_ON_HEAD_FAILURE"), "When to warn about HEAD pull requests failing. Possible values: always, auto or never") + flags.Bool( + "notification-log-stdout", + viper.GetBool("WATCHTOWER_NOTIFICATION_LOG_STDOUT"), + "Write notification logs to stdout instead of logging (to stderr)") } // SetDefaults provides default values for environment variables @@ -504,3 +519,60 @@ func isFile(s string) bool { _, err := os.Stat(s) return !errors.Is(err, os.ErrNotExist) } + +// ProcessFlagAliases updates the value of flags that are being set by helper flags +func ProcessFlagAliases(flags *pflag.FlagSet) { + + porcelain, err := flags.GetString(`porcelain`) + if err != nil { + log.Fatalf(`Failed to get flag: %v`, err) + } + if porcelain != "" { + if porcelain != "v1" { + log.Fatalf(`Unknown porcelain version %q. Supported values: "v1"`, porcelain) + } + if err = appendFlagValue(flags, `notification-url`, `logger://`); err != nil { + log.Errorf(`Failed to set flag: %v`, err) + } + setFlagIfDefault(flags, `notification-log-stdout`, `true`) + setFlagIfDefault(flags, `notification-report`, `true`) + tpl := fmt.Sprintf(`porcelain.%s.summary-no-log`, porcelain) + setFlagIfDefault(flags, `notification-template`, tpl) + } + + if flags.Changed(`interval`) && flags.Changed(`schedule`) { + log.Fatal(`Only schedule or interval can be defined, not both.`) + } + + // update schedule flag to match interval if it's set, or to the default if none of them are + if flags.Changed(`interval`) || !flags.Changed(`schedule`) { + interval, _ := flags.GetInt(`interval`) + flags.Set(`schedule`, fmt.Sprintf(`@every %ds`, interval)) + } +} + +func appendFlagValue(flags *pflag.FlagSet, name string, values ...string) error { + flag := flags.Lookup(name) + if flag == nil { + return fmt.Errorf(`invalid flag name %q`, name) + } + + if flagValues, ok := flag.Value.(pflag.SliceValue); ok { + for _, value := range values { + flagValues.Append(value) + } + } else { + return fmt.Errorf(`the value for flag %q is not a slice value`, name) + } + + return nil +} + +func setFlagIfDefault(flags *pflag.FlagSet, name string, value string) { + if flags.Changed(name) { + return + } + if err := flags.Set(name, value); err != nil { + log.Errorf(`Failed to set flag: %v`, err) + } +} diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go index 7d3c55e..570ebf5 100644 --- a/internal/flags/flags_test.go +++ b/internal/flags/flags_test.go @@ -5,6 +5,7 @@ import ( "os" "testing" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -127,3 +128,71 @@ func TestIsFile(t *testing.T) { assert.False(t, isFile("https://google.com"), "an URL should never be considered a file") assert.True(t, isFile(os.Args[0]), "the currently running binary path should always be considered a file") } + +func TestReadFlags(t *testing.T) { + logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() } + +} + +func TestProcessFlagAliases(t *testing.T) { + logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() } + cmd := new(cobra.Command) + SetDefaults() + RegisterDockerFlags(cmd) + RegisterSystemFlags(cmd) + RegisterNotificationFlags(cmd) + + require.NoError(t, cmd.ParseFlags([]string{ + `--porcelain`, `v1`, + `--interval`, `10`, + })) + flags := cmd.Flags() + ProcessFlagAliases(flags) + + urls, _ := flags.GetStringArray(`notification-url`) + assert.Contains(t, urls, `logger://`) + + logStdout, _ := flags.GetBool(`notification-log-stdout`) + assert.True(t, logStdout) + + report, _ := flags.GetBool(`notification-report`) + assert.True(t, report) + + template, _ := flags.GetString(`notification-template`) + assert.Equal(t, `porcelain.v1.summary-no-log`, template) + + sched, _ := flags.GetString(`schedule`) + assert.Equal(t, `@every 10s`, sched) +} + +func TestProcessFlagAliasesSchedAndInterval(t *testing.T) { + logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) } + cmd := new(cobra.Command) + SetDefaults() + RegisterDockerFlags(cmd) + RegisterSystemFlags(cmd) + RegisterNotificationFlags(cmd) + + require.NoError(t, cmd.ParseFlags([]string{`--schedule`, `@now`, `--interval`, `10`})) + flags := cmd.Flags() + + assert.PanicsWithValue(t, `FATAL`, func() { + ProcessFlagAliases(flags) + }) +} + +func TestProcessFlagAliasesInvalidPorcelaineVersion(t *testing.T) { + logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) } + cmd := new(cobra.Command) + SetDefaults() + RegisterDockerFlags(cmd) + RegisterSystemFlags(cmd) + RegisterNotificationFlags(cmd) + + require.NoError(t, cmd.ParseFlags([]string{`--porcelain`, `cowboy`})) + flags := cmd.Flags() + + assert.PanicsWithValue(t, `FATAL`, func() { + ProcessFlagAliases(flags) + }) +} diff --git a/pkg/notifications/common_templates.go b/pkg/notifications/common_templates.go new file mode 100644 index 0000000..64a53c0 --- /dev/null +++ b/pkg/notifications/common_templates.go @@ -0,0 +1,39 @@ +package notifications + +var commonTemplates = map[string]string{ + `default-legacy`: "{{range .}}{{.Message}}{{println}}{{end}}", + + `default`: ` +{{- if .Report -}} + {{- with .Report -}} + {{- if ( or .Updated .Failed ) -}} +{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed + {{- range .Updated}} +- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}} + {{- end -}} + {{- range .Fresh}} +- {{.Name}} ({{.ImageName}}): {{.State}} + {{- end -}} + {{- range .Skipped}} +- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} + {{- end -}} + {{- range .Failed}} +- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- else -}} + {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}} +{{- end -}}`, + + `porcelain.v1.summary-no-log`: ` +{{- if .Report -}} + {{- range .Report.All }} + {{- .Name}} ({{.ImageName}}): {{.State -}} + {{- with .Error}} Error: {{.}}{{end}}{{ println }} + {{- else -}} + no containers matched filter + {{- end -}} +{{- end -}}`, +} + diff --git a/pkg/notifications/email.go b/pkg/notifications/email.go index e162209..3ebb4c0 100644 --- a/pkg/notifications/email.go +++ b/pkg/notifications/email.go @@ -15,7 +15,6 @@ const ( ) type emailTypeNotifier struct { - url string From, To string Server, User, Password, SubjectTag string Port int diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go index d4c8601..fba5dc0 100644 --- a/pkg/notifications/notifier.go +++ b/pkg/notifications/notifier.go @@ -21,20 +21,21 @@ func NewNotifier(c *cobra.Command) ty.Notifier { log.Fatalf("Notifications invalid log level: %s", err.Error()) } - acceptedLogLevels := slackrus.LevelThreshold(logLevel) + levels := slackrus.LevelThreshold(logLevel) // slackrus does not allow log level TRACE, even though it's an accepted log level for logrus - if len(acceptedLogLevels) == 0 { + if len(levels) == 0 { log.Fatalf("Unsupported notification log level provided: %s", level) } reportTemplate, _ := f.GetBool("notification-report") + stdout, _ := f.GetBool("notification-log-stdout") tplString, _ := f.GetString("notification-template") urls, _ := f.GetStringArray("notification-url") data := GetTemplateData(c) urls, delay := AppendLegacyUrls(urls, c, data.Title) - return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, data, delay, urls...) + return newShoutrrrNotifier(tplString, levels, !reportTemplate, data, delay, stdout, urls...) } // AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go index 3940d22..e816cf7 100644 --- a/pkg/notifications/shoutrrr.go +++ b/pkg/notifications/shoutrrr.go @@ -3,6 +3,7 @@ package notifications import ( "bytes" stdlog "log" + "os" "strings" "text/template" "time" @@ -11,35 +12,14 @@ import ( "github.com/containrrr/shoutrrr/pkg/types" t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // LocalLog is a logrus logger that does not send entries as notifications var LocalLog = log.WithField("notify", "no") const ( - shoutrrrDefaultLegacyTemplate = "{{range .}}{{.Message}}{{println}}{{end}}" - shoutrrrDefaultTemplate = ` -{{- if .Report -}} - {{- with .Report -}} - {{- if ( or .Updated .Failed ) -}} -{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed - {{- range .Updated}} -- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}} - {{- end -}} - {{- range .Fresh}} -- {{.Name}} ({{.ImageName}}): {{.State}} - {{- end -}} - {{- range .Skipped}} -- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} - {{- end -}} - {{- range .Failed}} -- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} - {{- end -}} - {{- end -}} - {{- end -}} -{{- else -}} - {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}} -{{- end -}}` shoutrrrType = "shoutrrr" ) @@ -79,9 +59,9 @@ func (n *shoutrrrTypeNotifier) GetNames() []string { return names } -func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, data StaticData, delay time.Duration, urls ...string) t.Notifier { +func newShoutrrrNotifier(tplString string, levels []log.Level, legacy bool, data StaticData, delay time.Duration, stdout bool, urls ...string) t.Notifier { - notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy, data) + notifier := createNotifier(urls, levels, tplString, legacy, data, stdout) log.AddHook(notifier) // Do the sending in a separate goroutine so we don't block the main process. @@ -90,14 +70,19 @@ func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy return notifier } -func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool, data StaticData) *shoutrrrTypeNotifier { +func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool, data StaticData, stdout bool) *shoutrrrTypeNotifier { tpl, err := getShoutrrrTemplate(tplString, legacy) if err != nil { log.Errorf("Could not use configured notification template: %s. Using default template", err) } - traceWriter := log.StandardLogger().WriterLevel(log.TraceLevel) - r, err := shoutrrr.NewSender(stdlog.New(traceWriter, "Shoutrrr: ", 0), urls...) + var logger types.StdLogger + if stdout { + logger = stdlog.New(os.Stdout, ``, 0) + } else { + logger = stdlog.New(log.StandardLogger().WriterLevel(log.TraceLevel), "Shoutrrr: ", 0) + } + r, err := shoutrrr.NewSender(logger, urls...) if err != nil { log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error()) } @@ -190,7 +175,7 @@ func (n *shoutrrrTypeNotifier) Close() { // Use fmt so it doesn't trigger another notification. LocalLog.Info("Waiting for the notification goroutine to finish") - _ = <-n.done + <-n.done } // Levels return what log levels trigger notifications @@ -217,10 +202,15 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, funcs := template.FuncMap{ "ToUpper": strings.ToUpper, "ToLower": strings.ToLower, - "Title": strings.Title, + "Title": cases.Title(language.AmericanEnglish).String, } tplBase := template.New("").Funcs(funcs) + if builtin, found := commonTemplates[tplString]; found { + log.WithField(`template`, tplString).Debug(`Using common template`) + tplString = builtin + } + // If we succeed in getting a non-empty template configuration // try to parse the template string. if tplString != "" { @@ -228,16 +218,16 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, } // If we had an error (either from parsing the template string - // or from getting the template configuration) or we a + // or from getting the template configuration) or a // template wasn't configured (the empty template string) // fallback to using the default template. if err != nil || tplString == "" { - defaultTemplate := shoutrrrDefaultTemplate + defaultKey := `default` if legacy { - defaultTemplate = shoutrrrDefaultLegacyTemplate + defaultKey = `default-legacy` } - tpl = template.Must(tplBase.Parse(defaultTemplate)) + tpl = template.Must(tplBase.Parse(commonTemplates[defaultKey])) } return diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go index 46ab78d..0a10eb1 100644 --- a/pkg/notifications/shoutrrr_test.go +++ b/pkg/notifications/shoutrrr_test.go @@ -73,6 +73,16 @@ var _ = Describe("Shoutrrr", func() { }) }) + When("passing a common template name", func() { + It("should format using that template", func() { + expected := ` +updt1 (mock/updt1:latest): Updated +`[1:] + data := mockDataFromStates(s.UpdatedState) + Expect(getTemplatedResult(`porcelain.v1.summary-no-log`, false, data)).To(Equal(expected)) + }) + }) + When("using legacy templates", func() { When("no custom template is provided", func() { @@ -80,7 +90,7 @@ var _ = Describe("Shoutrrr", func() { cmd := new(cobra.Command) flags.RegisterNotificationFlags(cmd) - shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{}) + shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{}, false) entries := []*logrus.Entry{ { @@ -168,7 +178,6 @@ var _ = Describe("Shoutrrr", func() { }) When("using report templates", func() { - When("no custom template is provided", func() { It("should format the messages using the default template", func() { expected := `4 Scanned, 2 Updated, 1 Failed @@ -236,7 +245,7 @@ Turns out everything is on fire When("batching notifications", func() { When("no messages are queued", func() { It("should not send any notification", func() { - shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), "logger://") + shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), false, "logger://") shoutrrr.StartNotification() shoutrrr.SendNotification(nil) Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`)) @@ -244,7 +253,7 @@ Turns out everything is on fire }) When("at least one message is queued", func() { It("should send a notification", func() { - shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), "logger://") + shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), false, "logger://") shoutrrr.StartNotification() logrus.Info("This log message is sponsored by ContainrrrVPN") shoutrrr.SendNotification(nil) @@ -258,7 +267,7 @@ Turns out everything is on fire shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{ Host: "test.host", Title: "", - }) + }, false) _, found := shoutrrr.params.Title() Expect(found).ToNot(BeTrue()) }) @@ -290,7 +299,7 @@ type blockingRouter struct { } func (b blockingRouter) Send(_ string, _ *types.Params) []error { - _ = <-b.unlock + <-b.unlock b.sent <- true return nil } diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go index f4fa158..34d21a3 100644 --- a/pkg/notifications/slack.go +++ b/pkg/notifications/slack.go @@ -43,7 +43,7 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) { trimmedURL := strings.TrimRight(s.HookURL, "/") - trimmedURL = strings.TrimLeft(trimmedURL, "https://") + trimmedURL = strings.TrimPrefix(trimmedURL, "https://") parts := strings.Split(trimmedURL, "/") if parts[0] == "discord.com" || parts[0] == "discordapp.com" { diff --git a/pkg/session/report.go b/pkg/session/report.go index 646a0c0..707eb91 100644 --- a/pkg/session/report.go +++ b/pkg/session/report.go @@ -1,8 +1,9 @@ package session import ( - "github.com/containrrr/watchtower/pkg/types" "sort" + + "github.com/containrrr/watchtower/pkg/types" ) type report struct { @@ -32,6 +33,33 @@ func (r *report) Stale() []types.ContainerReport { func (r *report) Fresh() []types.ContainerReport { return r.fresh } +func (r *report) All() []types.ContainerReport { + allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh) + all := make([]types.ContainerReport, 0, allLen) + + presentIds := map[types.ContainerID][]string{} + + appendUnique := func(reports []types.ContainerReport) { + for _, cr := range reports { + if _, found := presentIds[cr.ID()]; found { + continue + } + all = append(all, cr) + presentIds[cr.ID()] = nil + } + } + + appendUnique(r.updated) + appendUnique(r.failed) + appendUnique(r.skipped) + appendUnique(r.stale) + appendUnique(r.fresh) + appendUnique(r.scanned) + + sort.Sort(sortableContainers(all)) + + return all +} // NewReport creates a types.Report from the supplied Progress func NewReport(progress Progress) types.Report { diff --git a/pkg/types/report.go b/pkg/types/report.go index 8013b58..f454fc6 100644 --- a/pkg/types/report.go +++ b/pkg/types/report.go @@ -8,6 +8,7 @@ type Report interface { Skipped() []ContainerReport Stale() []ContainerReport Fresh() []ContainerReport + All() []ContainerReport } // ContainerReport represents a container that was included in watchtower session diff --git a/scripts/docker-util.sh b/scripts/docker-util.sh index 13a84ca..bd0dbda 100644 --- a/scripts/docker-util.sh +++ b/scripts/docker-util.sh @@ -122,4 +122,65 @@ function container-started() { return 1 fi docker container inspect "$Name" | jq -r .[].State.StartedAt +} + + +function container-exists() { + local Name=$1 + if [ -z "$Name" ]; then + echo "NAME missing" + return 1 + fi + + docker container inspect "$Name" 1> /dev/null 2> /dev/null +} + +function registry-exists() { + container-exists "$CONTAINER_PREFIX-registry" +} + +function create-container() { + local container_name=$1 + if [ -z "$container_name" ]; then + echo "NAME missing" + return 1 + fi + local image_name="${2:-$container_name}" + + echo -en "Creating \e[94m$container_name\e[0m container... " + local result + result=$(docker run -d --name "$container_name" "$(registry-host)/$image_name" 2>&1) + if [ "${#result}" -eq 64 ]; then + echo -e "\e[92m${result:0:12}\e[0m" + return 0 + else + echo -e "\e[91mFailed!\n\e[97m$result\e[0m" + return 1 + fi +} + +function remove-images() { + local image_name=$1 + if [ -z "$image_name" ]; then + echo "NAME missing" + return 1 + fi + + local images + mapfile -t images < <(docker images -q "$image_name" | uniq) + if [ -n "${images[*]}" ]; then + docker image rm "${images[@]}" + else + echo "No images matched \"$image_name\"" + fi +} + +function remove-repo-images() { + local image_name=$1 + if [ -z "$image_name" ]; then + echo "NAME missing" + return 1 + fi + + remove-images "$(registry-host)/images/$image_name" } \ No newline at end of file diff --git a/scripts/du-cli.sh b/scripts/du-cli.sh old mode 100644 new mode 100755 index 973dec5..611f720 --- a/scripts/du-cli.sh +++ b/scripts/du-cli.sh @@ -16,7 +16,7 @@ case $1 in registry-host ;; *) - echo "Unknown keyword \"$2\"" + echo "Unknown registry action \"$2\"" ;; esac ;; @@ -28,8 +28,11 @@ case $1 in latest) latest-image-rev "$3" ;; + rm) + remove-repo-images "$3" + ;; *) - echo "Unknown keyword \"$2\"" + echo "Unknown image action \"$2\"" ;; esac ;; @@ -47,8 +50,26 @@ case $1 in started) container-started "$3" ;; + create) + create-container "${@:3:2}" + ;; + create-stale) + if [ -z "$3" ]; then + echo "NAME missing" + exit 1 + fi + if ! registry-exists; then + echo "Registry container missing! Creating..." + start-registry || exit 1 + fi + image_name="images/$3" + container_name=$3 + $0 image rev "$image_name" || exit 1 + $0 container create "$container_name" "$image_name" || exit 1 + $0 image rev "$image_name" || exit 1 + ;; *) - echo "Unknown keyword \"$2\"" + echo "Unknown container action \"$2\"" ;; esac ;;