feat: add porcelain output (#1337)

* feat: add porcaline output

* feat(du-cli): add create-stale action

add create-stale action

Signed-off-by: nils måsén

* test(flags): add alias tests

* fix stray format string ref

* fix shell liniting problems

* feat(du-cli): remove created images

* add test for common template

* fix interval/schedule logic

* use porcelain arg as template version

* fix editor save artifacts

* use simpler v1 template

Signed-off-by: nils måsén
pull/1347/head
nils måsén 2 years ago committed by GitHub
parent a429c373ff
commit 7900471f88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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"
}
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)

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

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

@ -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 -}}`,
}

@ -15,7 +15,6 @@ const (
)
type emailTypeNotifier struct {
url string
From, To string
Server, User, Password, SubjectTag string
Port int

@ -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

@ -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

@ -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
}

@ -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" {

@ -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 {

@ -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

@ -123,3 +123,64 @@ function container-started() {
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"
}

@ -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
;;

Loading…
Cancel
Save