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
This commit is contained in:
nils måsén 2022-08-14 10:11:31 +02:00 committed by GitHub
parent a429c373ff
commit 7900471f88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 344 additions and 63 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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