Repository: containrrr/watchtower (go)

Issue:
WATCHTOWER_NOTIFICATION_TEMPLATE wierd behavior
### Describe the bug

When I use `{{- toJson .Entries -}}` in `WATCHTOWER_NOTIFICATION_TEMPLATE`, the output doesn't actually look like json.

the first output I get is:
```
Watchtower 1.5.1
Using notifications: generic+https
Checking all containers (except explicitly disabled with label)
Scheduling first run: 2022-11-16 19:20:00 +0100 CET
Note that the first check will be performed in 3 minutes, 11 seconds
```
Wich is not a json object.

### Steps to reproduce

My conf is: 
```
 environment:
      TZ: Europe/Paris
      WATCHTOWER_CLEANUP: true
      WATCHTOWER_NOTIFICATIONS_HOSTNAME: watchtower
      WATCHTOWER_NO_STARTUP_MESSAGE: false
      WATCHTOWER_SCHEDULE: "* */5 * * * *"
      WATCHTOWER_NOTIFICATION_URL: generic+https://XXXXXXXX.x.pipedream.net
      WATCHTOWER_NOTIFICATION_REPORT: true
      WATCHTOWER_NOTIFICATION_TEMPLATE: >
        {{- if .Report -}}
          {{- toJson .Report -}}
         {{- else -}}
          {{- toJson .Entries -}}
        {{- end -}}
```

### Expected behavior

I should get a valid json object I guess

### Screenshots

_No response_

### Environment

Watchtower 1.5.1 on Linux Docker

### Your logs

```text
N/A
```


### Additional context

_No response_

Pull request description:
feat(notifications): add json template
This adds the `ToJSON` template function to notifications, allows the template data to JSON serialized and adds a built in default JSON template (simply `{{ . | ToJSON }}`).

<!--

Thank you for contributing to the watchtower project! 🙏

We truly appreciate all the contributions we get from the community.

To make your PR experience as smooth as possible, make sure that you
include the following in your PR:

- What your PR contributes
- Which issues it solves (preferrably using auto closing instructions like "closes #123".
- Tests that verify the code your contributing
- Updates to the documentation

Thank you again! ✨

-->
Fixes #1478 and allows for better compatibility with #1523.

Patch:
diff --git a/pkg/notifications/common_templates.go b/pkg/notifications/common_templates.go
index 64a53c0..84c0f54 100644
--- a/pkg/notifications/common_templates.go
+++ b/pkg/notifications/common_templates.go
@@ -35,5 +35,6 @@ var commonTemplates = map[string]string{
     no containers matched filter
   {{- end -}}
 {{- end -}}`,
-}
 
+	`json.v1`: `{{ . | ToJSON }}`,
+}
diff --git a/pkg/notifications/json.go b/pkg/notifications/json.go
new file mode 100644
index 0000000..1bd304a
--- /dev/null
+++ b/pkg/notifications/json.go
@@ -0,0 +1,71 @@
+package notifications
+
+import (
+	"encoding/json"
+
+	t "github.com/containrrr/watchtower/pkg/types"
+)
+
+type jsonMap = map[string]interface{}
+
+// MarshalJSON implements json.Marshaler
+func (d Data) MarshalJSON() ([]byte, error) {
+	var entries = make([]jsonMap, len(d.Entries))
+	for i, entry := range d.Entries {
+		entries[i] = jsonMap{
+			`level`:   entry.Level,
+			`message`: entry.Message,
+			`data`:    entry.Data,
+			`time`:    entry.Time,
+		}
+	}
+
+	var report jsonMap
+	if d.Report != nil {
+		report = jsonMap{
+			`scanned`: marshalReports(d.Report.Scanned()),
+			`updated`: marshalReports(d.Report.Updated()),
+			`failed`:  marshalReports(d.Report.Failed()),
+			`skipped`: marshalReports(d.Report.Skipped()),
+			`stale`:   marshalReports(d.Report.Stale()),
+			`fresh`:   marshalReports(d.Report.Fresh()),
+		}
+	}
+
+	return json.Marshal(jsonMap{
+		`report`:  report,
+		`title`:   d.Title,
+		`host`:    d.Host,
+		`entries`: entries,
+	})
+}
+
+func marshalReports(reports []t.ContainerReport) []jsonMap {
+	jsonReports := make([]jsonMap, len(reports))
+	for i, report := range reports {
+		jsonReports[i] = jsonMap{
+			`id`:             report.ID().ShortID(),
+			`name`:           report.Name(),
+			`currentImageId`: report.CurrentImageID().ShortID(),
+			`latestImageId`:  report.LatestImageID().ShortID(),
+			`imageName`:      report.ImageName(),
+			`state`:          report.State(),
+		}
+		if errorMessage := report.Error(); errorMessage != "" {
+			jsonReports[i][`error`] = errorMessage
+		}
+	}
+	return jsonReports
+}
+
+var _ json.Marshaler = &Data{}
+
+func toJSON(v interface{}) string {
+	var bytes []byte
+	var err error
+	if bytes, err = json.MarshalIndent(v, "", "  "); err != nil {
+		LocalLog.Errorf("failed to marshal JSON in notification template: %v", err)
+		return ""
+	}
+	return string(bytes)
+}
diff --git a/pkg/notifications/model.go b/pkg/notifications/model.go
new file mode 100644
index 0000000..83c97ba
--- /dev/null
+++ b/pkg/notifications/model.go
@@ -0,0 +1,19 @@
+package notifications
+
+import (
+	t "github.com/containrrr/watchtower/pkg/types"
+	log "github.com/sirupsen/logrus"
+)
+
+// StaticData is the part of the notification template data model set upon initialization
+type StaticData struct {
+	Title string
+	Host  string
+}
+
+// Data is the notification template data model
+type Data struct {
+	StaticData
+	Entries []*log.Entry
+	Report  t.Report
+}
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index 47141e8..d6ce859 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -210,6 +210,7 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
 	funcs := template.FuncMap{
 		"ToUpper": strings.ToUpper,
 		"ToLower": strings.ToLower,
+		"ToJSON":  toJSON,
 		"Title":   cases.Title(language.AmericanEnglish).String,
 	}
 	tplBase := template.New("").Funcs(funcs)
@@ -240,16 +241,3 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
 
 	return
 }
-
-// StaticData is the part of the notification template data model set upon initialization
-type StaticData struct {
-	Title string
-	Host  string
-}
-
-// Data is the notification template data model
-type Data struct {
-	StaticData
-	Entries []*log.Entry
-	Report  t.Report
-}


Test patch:
diff --git a/pkg/notifications/json_test.go b/pkg/notifications/json_test.go
new file mode 100644
index 0000000..ef30c59
--- /dev/null
+++ b/pkg/notifications/json_test.go
@@ -0,0 +1,118 @@
+package notifications
+
+import (
+	s "github.com/containrrr/watchtower/pkg/session"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("JSON template", func() {
+	When("using report templates", func() {
+		When("JSON template is used", func() {
+			It("should format the messages to the expected format", func() {
+				expected := `{
+	"entries": [
+			{
+				"data": null,
+				"level": "info",
+				"message": "foo Bar",
+				"time": "0001-01-01T00:00:00Z"
+			}
+		],
+		"host": "Mock",
+		"report": {
+		"failed": [
+			{
+				"currentImageId": "01d210000000",
+				"error": "accidentally the whole container",
+				"id": "c79210000000",
+				"imageName": "mock/fail1:latest",
+				"latestImageId": "d0a210000000",
+				"name": "fail1",
+				"state": "Failed"
+			}
+		],
+		"fresh": [
+			{
+				"currentImageId": "01d310000000",
+				"id": "c79310000000",
+				"imageName": "mock/frsh1:latest",
+				"latestImageId": "01d310000000",
+				"name": "frsh1",
+				"state": "Fresh"
+			}
+		],
+		"scanned": [
+			{
+				"currentImageId": "01d110000000",
+				"id": "c79110000000",
+				"imageName": "mock/updt1:latest",
+				"latestImageId": "d0a110000000",
+				"name": "updt1",
+				"state": "Updated"
+			},
+			{
+				"currentImageId": "01d120000000",
+				"id": "c79120000000",
+				"imageName": "mock/updt2:latest",
+				"latestImageId": "d0a120000000",
+				"name": "updt2",
+				"state": "Updated"
+			},
+			{
+				"currentImageId": "01d210000000",
+				"error": "accidentally the whole container",
+				"id": "c79210000000",
+				"imageName": "mock/fail1:latest",
+				"latestImageId": "d0a210000000",
+				"name": "fail1",
+				"state": "Failed"
+			},
+			{
+				"currentImageId": "01d310000000",
+				"id": "c79310000000",
+				"imageName": "mock/frsh1:latest",
+				"latestImageId": "01d310000000",
+				"name": "frsh1",
+				"state": "Fresh"
+			}
+		],
+		"skipped": [
+			{
+				"currentImageId": "01d410000000",
+				"error": "unpossible",
+				"id": "c79410000000",
+				"imageName": "mock/skip1:latest",
+				"latestImageId": "01d410000000",
+				"name": "skip1",
+				"state": "Skipped"
+			}
+		],
+		"stale": [],
+		"updated": [
+			{
+				"currentImageId": "01d110000000",
+				"id": "c79110000000",
+				"imageName": "mock/updt1:latest",
+				"latestImageId": "d0a110000000",
+				"name": "updt1",
+				"state": "Updated"
+			},
+			{
+				"currentImageId": "01d120000000",
+				"id": "c79120000000",
+				"imageName": "mock/updt2:latest",
+				"latestImageId": "d0a120000000",
+				"name": "updt2",
+				"state": "Updated"
+			}
+		]
+		},
+		"title": "Watchtower updates on Mock"
+}`
+				data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState)
+				Expect(getTemplatedResult(`json.v1`, false, data)).To(MatchJSON(expected))
+			})
+		})
+	})
+})