feat: add tplprev wasm

This commit is contained in:
nils måsén 2023-10-02 11:37:30 +02:00
parent 9b28fbc24d
commit 16883d21c0
11 changed files with 1348 additions and 19 deletions

View file

@ -17,6 +17,13 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.18.x
- name: Build tplprev
run: |
GOARCH=wasm GOOS=js go build -o ./docs/assets/tplprev.wasm ./tplprev
- name: Setup python
uses: actions/setup-python@v4
with:

554
docs/assets/wasm_exec.js Normal file
View file

@ -0,0 +1,554 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substr(0, nl));
outputBuf = outputBuf.substr(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
go: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();

192
docs/template-preview.md Normal file
View file

@ -0,0 +1,192 @@
<style>
body {font-size: .75rem;}
textarea {
box-decoration-break: slice;
overflow: auto;
padding: 0.77em 1.18em;
scrollbar-color: var(--md-default-fg-color--lighter) transparent;
scrollbar-width: thin;
touch-action: auto;
word-break: normal;
height: 420px;
}
textarea, input {
background-color: var(--md-code-bg-color);
border-width: 0;
border-radius: 0.1rem;
color: var(--md-code-fg-color);
font-feature-settings: "kern";
font-family: var(--md-code-font-family);
}
.numfield {
font-size: .7rem;
display: flex;
flex-direction: column;
justify-content: space-between;
}
label:not(:last-child) {
/* margin-bottom: 0.5rem; */
}
button {
border-radius: 0.1rem;
color: var(--md-typeset-color);
background-color: var(--md-primary-fg-color);
}
button:hover {
background-color: var(--md-accent-fg-color);
}
input[type="number"] { width: 5ch; flex: 1; font-size: 1rem; }
fieldset {
margin-top: -0.5rem;
display: flex;
flex: 1;
column-gap: 0.5rem;
}
#result {
font-size: 0.7rem;
background-color: var(--md-code-bg-color);
scrollbar-color: var(--md-default-fg-color--lighter) transparent;
scrollbar-width: thin;
touch-action: auto;
overflow: auto;
padding: 0.77em 1.18em;
margin:0;
}
</style>
<script src="../assets/wasm_exec.js"></script>
<script>
const updatePreview = () => {
const form = document.querySelector('#tplprev');
const input = form.template.value;
console.log('Input: %o', input);
const actions = form.enablereport.checked ? [
[ form.skipped.valueAsNumber, "skipped" ],
[ form.scanned.valueAsNumber, "scanned" ],
[ form.updated.valueAsNumber, "updated" ],
[ form.failed.valueAsNumber, "failed" ],
[ form.fresh.valueAsNumber, "fresh" ],
[ form.stale.valueAsNumber, "stale" ],
] : [];
console.log("Actions: %o", actions);
const logentries = form.enablelog.checked ? [
form.logerrors.valueAsNumber,
form.logwarnings.valueAsNumber,
form.loginfos.valueAsNumber,
form.logdebugs.valueAsNumber,
] : [0, 0, 0, 0];
console.log("LogLevel counts: %o", logentries);
const output = WATCHTOWER.tplprev(input, actions, logentries);
console.log('Output: \n%o', output);
document.querySelector('#result').innerText = output;
}
const formSubmitted = (e) => {
console.log("Event: %o", e);
e.preventDefault();
updatePreview();
}
let debounce;
const inputUpdated = () => {
if(debounce) clearTimeout(debounce);
debounce = setTimeout(() => updatePreview(), 400);
}
const go = new Go();
WebAssembly.instantiateStreaming(fetch("../assets/tplprev.wasm"), go.importObject).then((result) => {
document.querySelector('#loading').style.display = "none";
go.run(result.instance);
updatePreview();
});
</script>
<form id="tplprev" onsubmit="formSubmitted(event)" style="margin: 0;display: flex; flex-direction: column; row-gap: 1rem; box-sizing: border-box; position: relative; margin-right: -13.3rem">
<pre id="loading" style="position: absolute; inset: 0; display: flex; padding: 1rem; box-sizing: border-box; background: var(--md-code-bg-color); margin-top: 0">loading wasm...</pre>
<div style="display: flex; flex:1; column-gap: 1rem;">
<textarea name="template" type="text" style="flex: 1" onkeyup="inputUpdated()">{{- 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 -}}
{{- if (and .Entries .Report) }}
Logs:
{{ end -}}
{{range .Entries -}}{{.Time.Format "2006-01-02T15:04:05Z07:00"}} [{{.Level}}] {{.Message}}{{"\n"}}{{- end -}}</textarea>
</div>
<div style="display: flex; flex-direction: row; column-gap: 0.5rem">
<fieldset>
<legend><label><input type="checkbox" name="enablereport" checked /> Container report</label></legend>
<label class="numfield">
Skipped:
<input type="number" name="skipped" value="3" />
</label>
<label class="numfield">
Scanned:
<input type="number" name="scanned" value="3" />
</label>
<label class="numfield">
Updated:
<input type="number" name="updated" value="3" />
</label>
<label class="numfield">
Failed:
<input type="number" name="failed" value="3" />
</label>
<label class="numfield">
Fresh:
<input type="number" name="fresh" value="3" />
</label>
<label class="numfield">
Stale:
<input type="number" name="stale" value="3" />
</label>
</fieldset>
<fieldset>
<legend><label><input type="checkbox" name="enablelog" checked /> Log entries</label></legend>
<label class="numfield">
Error:
<input type="number" name="logerrors" value="1" />
</label>
<label class="numfield">
Warning:
<input type="number" name="logwarnings" value="2" />
</label>
<label class="numfield">
Info:
<input type="number" name="loginfos" value="3" />
</label>
<label class="numfield">
Debug:
<input type="number" name="logdebugs" value="4" />
</label>
</fieldset>
<button type="submit" style="flex:1; min-width: 12ch; padding: 0.5rem">Update preview</button>
</div>
<div style="flex: 1; display: flex">
<pre style="flex:1; width:100%" id="result"></pre>
</div>

View file

@ -59,13 +59,3 @@ func marshalReports(reports []t.ContainerReport) []jsonMap {
}
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)
}

View file

@ -10,10 +10,9 @@ import (
"github.com/containrrr/shoutrrr"
"github.com/containrrr/shoutrrr/pkg/types"
"github.com/containrrr/watchtower/pkg/notifications/templates"
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
@ -208,13 +207,8 @@ func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
}
func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, err error) {
funcs := template.FuncMap{
"ToUpper": strings.ToUpper,
"ToLower": strings.ToLower,
"ToJSON": toJSON,
"Title": cases.Title(language.AmericanEnglish).String,
}
tplBase := template.New("").Funcs(funcs)
tplBase := template.New("").Funcs(templates.Funcs)
if builtin, found := commonTemplates[tplString]; found {
log.WithField(`template`, tplString).Debug(`Using common template`)
@ -242,3 +236,7 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
return
}
func GetShoutrrrTemplate(tplString string) (tpl *template.Template, err error) {
return getShoutrrrTemplate(tplString, false)
}

View file

@ -0,0 +1,27 @@
package templates
import (
"encoding/json"
"fmt"
"strings"
"text/template"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var Funcs = template.FuncMap{
"ToUpper": strings.ToUpper,
"ToLower": strings.ToLower,
"ToJSON": toJSON,
"Title": cases.Title(language.AmericanEnglish).String,
}
func toJSON(v interface{}) string {
var bytes []byte
var err error
if bytes, err = json.MarshalIndent(v, "", " "); err != nil {
return fmt.Sprintf("failed to marshal JSON in notification template: %v", err)
}
return string(bytes)
}

57
tplprev/data.go Normal file
View file

@ -0,0 +1,57 @@
package main
import (
"time"
"github.com/containrrr/watchtower/pkg/types"
)
type Data struct {
Entries []*LogEntry
StaticData StaticData
Report types.Report
}
type StaticData struct {
Title string
Host string
}
type LogEntry struct {
Message string
Data map[string]any
Time time.Time
Level LogLevel
}
type LogLevel int
const (
PanicLevel LogLevel = iota
FatalLevel
ErrorLevel
WarnLevel
InfoLevel
DebugLevel
TraceLevel
)
func (level LogLevel) String() string {
switch level {
case TraceLevel:
return "trace"
case DebugLevel:
return "debug"
case InfoLevel:
return "info"
case WarnLevel:
return "warning"
case ErrorLevel:
return "error"
case FatalLevel:
return "fatal"
case PanicLevel:
return "panic"
}
return ""
}

89
tplprev/main.go Normal file
View file

@ -0,0 +1,89 @@
package main
import (
"fmt"
"strings"
"text/template"
"time"
"github.com/containrrr/watchtower/internal/meta"
"github.com/containrrr/watchtower/pkg/notifications/templates"
"syscall/js"
)
func main() {
fmt.Println("watchtower/tplprev v" + meta.Version)
js.Global().Set("WATCHTOWER", js.ValueOf(map[string]any{
"tplprev": js.FuncOf(tplprev),
}))
<-make(chan bool)
}
func tplprev(this js.Value, args []js.Value) any {
rb := ReportBuilder()
if len(args) < 2 {
return "Requires 3 argument passed"
}
input := args[0].String()
tpl, err := template.New("").Funcs(templates.Funcs).Parse(input)
if err != nil {
return "Failed to parse template: " + err.Error()
}
actionsArg := args[1]
for i := 0; i < actionsArg.Length(); i++ {
action := actionsArg.Index(i)
if action.Length() != 2 {
return fmt.Sprintf("Invalid size of action tuple, expected 2, got %v", action.Length())
}
count := action.Index(0).Int()
state := State(action.Index(1).String())
rb.AddNContainers(count, state)
}
entriesArg := args[2]
var entries []*LogEntry
for i := 0; i < entriesArg.Length(); i++ {
count := entriesArg.Index(i).Int()
level := ErrorLevel + LogLevel(i)
for m := 0; m < count; m++ {
var msg string
if level <= WarnLevel {
msg = rb.randomEntry(logErrors)
} else {
msg = rb.randomEntry(logMessages)
}
entries = append(entries, &LogEntry{
Message: msg,
Data: map[string]any{},
Time: time.Now(),
Level: level,
})
}
}
report := rb.Build()
data := Data{
Entries: entries,
StaticData: StaticData{
Title: "Title",
Host: "Host",
},
Report: report,
}
var buf strings.Builder
err = tpl.Execute(&buf, data)
if err != nil {
return "Failed to execute template: " + err.Error()
}
return buf.String()
}

178
tplprev/randnames.go Normal file
View file

@ -0,0 +1,178 @@
package main
var containerNames = []string{
"cyberscribe",
"datamatrix",
"nexasync",
"quantumquill",
"aerosphere",
"virtuos",
"fusionflow",
"neuralink",
"pixelpulse",
"synthwave",
"codecraft",
"zapzone",
"robologic",
"dreamstream",
"infinisync",
"megamesh",
"novalink",
"xenogenius",
"ecosim",
"innovault",
"techtracer",
"fusionforge",
"quantumquest",
"neuronest",
"codefusion",
"datadyno",
"pixelpioneer",
"vortexvision",
"cybercraft",
"synthsphere",
"infinitescript",
"roborhythm",
"dreamengine",
"aquasync",
"geniusgrid",
"megamind",
"novasync-pro",
"xenonwave",
"ecologic",
"innoscan",
}
var companyNames = []string{
"techwave",
"codecrafters",
"innotechlabs",
"fusionsoft",
"cyberpulse",
"quantumscribe",
"datadynamo",
"neuralink",
"pixelpro",
"synthwizards",
"virtucorplabs",
"robologic",
"dreamstream",
"novanest",
"megamind",
"xenonwave",
"ecologic",
"innosync",
"techgenius",
"nexasoft",
"codewave",
"zapzone",
"techsphere",
"aquatech",
"quantumcraft",
"neuronest",
"datafusion",
"pixelpioneer",
"synthsphere",
"infinitescribe",
"roborhythm",
"dreamengine",
"vortexvision",
"geniusgrid",
"megamesh",
"novasync",
"xenogeniuslabs",
"ecosim",
"innovault",
}
var errorMessages = []string{
"Error 404: Resource not found",
"Critical Error: System meltdown imminent",
"Error 500: Internal server error",
"Invalid input: Please check your data",
"Access denied: Unauthorized access detected",
"Network connection lost: Please check your connection",
"Error 403: Forbidden access",
"Fatal error: System crash imminent",
"File not found: Check the file path",
"Invalid credentials: Authentication failed",
"Error 502: Bad Gateway",
"Database connection failed: Please try again later",
"Security breach detected: Take immediate action",
"Error 400: Bad request",
"Out of memory: Close unnecessary applications",
"Invalid configuration: Check your settings",
"Error 503: Service unavailable",
"File is read-only: Cannot modify",
"Data corruption detected: Backup your data",
"Error 401: Unauthorized",
"Disk space full: Free up disk space",
"Connection timeout: Retry your request",
"Error 504: Gateway timeout",
"File access denied: Permission denied",
"Unexpected error: Please contact support",
"Error 429: Too many requests",
"Invalid URL: Check the URL format",
"Database query failed: Try again later",
"Error 408: Request timeout",
"File is in use: Close the file and try again",
"Invalid parameter: Check your input",
"Error 502: Proxy error",
"Database connection lost: Reconnect and try again",
"File size exceeds limit: Reduce the file size",
"Error 503: Overloaded server",
"Operation aborted: Try again",
"Invalid API key: Check your API key",
"Error 507: Insufficient storage",
"Database deadlock: Retry your transaction",
"Error 405: Method not allowed",
"File format not supported: Choose a different format",
"Unknown error: Contact system administrator",
}
var skippedMessages = []string{
"Fear of introducing new bugs",
"Don't have time for the update process",
"Current version works fine for my needs",
"Concerns about compatibility with other software",
"Limited bandwidth for downloading updates",
"Worries about losing custom settings or configurations",
"Lack of trust in the software developer's updates",
"Dislike changes to the user interface",
"Avoiding potential subscription fees",
"Suspicion of hidden data collection in updates",
"Apprehension about changes in privacy policies",
"Prefer the older version's features or design",
"Worry about software becoming more resource-intensive",
"Avoiding potential changes in licensing terms",
"Waiting for initial bugs to be resolved in the update",
"Concerns about update breaking third-party plugins or extensions",
"Belief that the software is already secure enough",
"Don't want to relearn how to use the software",
"Fear of losing access to older file formats",
"Avoiding the hassle of having to update multiple devices",
}
var logMessages = []string{
"Checking for available updates...",
"Downloading update package...",
"Verifying update integrity...",
"Preparing to install update...",
"Backing up existing configuration...",
"Installing update...",
"Update installation complete.",
"Applying configuration settings...",
"Cleaning up temporary files...",
"Update successful! Software is now up-to-date.",
"Restarting the application...",
"Restart complete. Enjoy the latest features!",
"Update rollback complete. Your software remains at the previous version.",
}
var logErrors = []string{
"Unable to check for updates. Please check your internet connection.",
"Update package download failed. Try again later.",
"Update verification failed. Please contact support.",
"Update installation failed. Rolling back to the previous version...",
"Your configuration settings may have been reset to defaults.",
}

186
tplprev/report.go Normal file
View file

@ -0,0 +1,186 @@
package main
import (
"encoding/hex"
"errors"
"fmt"
"math/rand"
"sort"
"strconv"
"strings"
"github.com/containrrr/watchtower/pkg/types"
)
type reportBuilder struct {
rand *rand.Rand
report Report
}
func ReportBuilder() *reportBuilder {
return &reportBuilder{
report: Report{},
rand: rand.New(rand.NewSource(1)),
}
}
type buildAction func(*reportBuilder)
func (rb *reportBuilder) Build() types.Report {
return &rb.report
}
func (rb *reportBuilder) AddNContainers(n int, state State) {
fmt.Printf("Adding %v containers with state %v", n, state)
for i := 0; i < n; i++ {
cid := types.ContainerID(rb.generateID())
old := types.ImageID(rb.generateID())
new := types.ImageID(rb.generateID())
name := rb.generateName()
image := rb.generateImageName(name)
var err error
if state == FailedState {
err = errors.New(rb.randomEntry(errorMessages))
} else if state == SkippedState {
err = errors.New(rb.randomEntry(skippedMessages))
}
rb.AddContainer(ContainerStatus{
containerID: cid,
oldImage: old,
newImage: new,
containerName: name,
imageName: image,
error: err,
state: state,
})
}
}
func (rb *reportBuilder) AddContainer(c ContainerStatus) {
switch c.state {
case ScannedState:
rb.report.scanned = append(rb.report.scanned, &c)
case UpdatedState:
rb.report.updated = append(rb.report.updated, &c)
case FailedState:
rb.report.failed = append(rb.report.failed, &c)
case SkippedState:
rb.report.skipped = append(rb.report.skipped, &c)
case StaleState:
rb.report.stale = append(rb.report.stale, &c)
case FreshState:
rb.report.fresh = append(rb.report.fresh, &c)
}
}
func (rb *reportBuilder) generateID() string {
buf := make([]byte, 32)
_, _ = rb.rand.Read(buf)
return hex.EncodeToString(buf)
}
func (rb *reportBuilder) randomEntry(arr []string) string {
return arr[rb.rand.Intn(len(arr))]
}
func (rb *reportBuilder) generateName() string {
index := rb.containerCount()
if index <= len(containerNames) {
return containerNames[index]
}
suffix := index / len(containerNames)
index %= len(containerNames)
return containerNames[index] + strconv.FormatInt(int64(suffix), 10)
}
func (rb *reportBuilder) generateImageName(name string) string {
index := rb.containerCount()
return companyNames[index%len(companyNames)] + "/" + strings.ToLower(name) + ":latest"
}
func (rb *reportBuilder) containerCount() int {
return len(rb.report.scanned) +
len(rb.report.updated) +
len(rb.report.failed) +
len(rb.report.skipped) +
len(rb.report.stale) +
len(rb.report.fresh)
}
type State string
const (
ScannedState State = "scanned"
UpdatedState State = "updated"
FailedState State = "failed"
SkippedState State = "skipped"
StaleState State = "stale"
FreshState State = "fresh"
)
type Report struct {
scanned []types.ContainerReport
updated []types.ContainerReport
failed []types.ContainerReport
skipped []types.ContainerReport
stale []types.ContainerReport
fresh []types.ContainerReport
}
func (r *Report) Scanned() []types.ContainerReport {
return r.scanned
}
func (r *Report) Updated() []types.ContainerReport {
return r.updated
}
func (r *Report) Failed() []types.ContainerReport {
return r.failed
}
func (r *Report) Skipped() []types.ContainerReport {
return r.skipped
}
func (r *Report) Stale() []types.ContainerReport {
return r.stale
}
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
}
type sortableContainers []types.ContainerReport
// Len implements sort.Interface.Len
func (s sortableContainers) Len() int { return len(s) }
// Less implements sort.Interface.Less
func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() }
// Swap implements sort.Interface.Swap
func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] }

51
tplprev/status.go Normal file
View file

@ -0,0 +1,51 @@
package main
import wt "github.com/containrrr/watchtower/pkg/types"
// ContainerStatus contains the container state during a session
type ContainerStatus struct {
containerID wt.ContainerID
oldImage wt.ImageID
newImage wt.ImageID
containerName string
imageName string
error
state State
}
// ID returns the container ID
func (u *ContainerStatus) ID() wt.ContainerID {
return u.containerID
}
// Name returns the container name
func (u *ContainerStatus) Name() string {
return u.containerName
}
// CurrentImageID returns the image ID that the container used when the session started
func (u *ContainerStatus) CurrentImageID() wt.ImageID {
return u.oldImage
}
// LatestImageID returns the newest image ID found during the session
func (u *ContainerStatus) LatestImageID() wt.ImageID {
return u.newImage
}
// ImageName returns the name:tag that the container uses
func (u *ContainerStatus) ImageName() string {
return u.imageName
}
// Error returns the error (if any) that was encountered for the container during a session
func (u *ContainerStatus) Error() string {
if u.error == nil {
return ""
}
return u.error.Error()
}
func (u *ContainerStatus) State() string {
return string(u.state)
}