From 46adbda36414fbad61e2bdf12e49af3cd67fefa9 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 31 Dec 2025 16:35:59 -0600 Subject: [PATCH] Update enroll diff --- enroll-diff.md | 125 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/enroll-diff.md b/enroll-diff.md index aeadc37..bb2b257 100644 --- a/enroll-diff.md +++ b/enroll-diff.md @@ -255,4 +255,129 @@ options: SMTP username (optional). --smtp-password-env SMTP_PASSWORD_ENV Environment variable containing SMTP password (optional). +``` + + +# Example Node-RED webhook receiver flow + +Here is an example Node-RED flow that could act as your webhook receiver. It has been preconfigured to parse the Enroll Diff payload. + +You would just need to add a downstream node to send the notification on to a service of your choosing (e.g Slack, Signal, Google Chat, a ticket system/API, etc) + +``` +[ + { + "id": "d84e1772e5d4cb01", + "type": "tab", + "label": "Enroll Diff Webhook", + "disabled": false, + "info": "", + "env": [] + }, + { + "id": "90a4098e08d188cc", + "type": "group", + "z": "d84e1772e5d4cb01", + "style": { + "stroke": "#999999", + "stroke-opacity": "1", + "fill": "none", + "fill-opacity": "1", + "label": true, + "label-position": "nw", + "color": "#a4a4a4" + }, + "nodes": [ + "ce827f8bd5838225", + "04768c32a4657ddf", + "6bb5aa95e1791737", + "fbfd6dacc8a6339d" + ], + "x": 214, + "y": 199, + "w": 932, + "h": 182 + }, + { + "id": "ce827f8bd5838225", + "type": "http in", + "z": "d84e1772e5d4cb01", + "g": "90a4098e08d188cc", + "name": "Webhook HTTP in", + "url": "/some/random/webhook/path", + "method": "post", + "upload": false, + "skipBodyParsing": false, + "swaggerDoc": "", + "x": 330, + "y": 300, + "wires": [ + [ + "04768c32a4657ddf" + ] + ] + }, + { + "id": "04768c32a4657ddf", + "type": "function", + "z": "d84e1772e5d4cb01", + "g": "90a4098e08d188cc", + "name": "Parse", + "func": "const r = msg.payload || {};\nconst MAX_CHARS = 2000;\nconst MAX_ITEMS = 25;\n\nfunction arr(x) { return Array.isArray(x) ? x : []; }\nfunction safe(s) { return (s === null || s === undefined) ? \"\" : String(s); }\n\nfunction formatList(title, items, prefix = \"• \") {\n items = arr(items).map(safe).filter(Boolean);\n if (items.length === 0) return \"\";\n\n const shown = items.slice(0, MAX_ITEMS);\n const more = items.length - shown.length;\n\n let line = `${title}: ${shown.join(\", \")}`;\n if (more > 0) line += ` … (+${more} more)`;\n return line;\n}\n\nfunction formatChangedUsers(changed) {\n changed = arr(changed);\n if (changed.length === 0) return \"\";\n\n const shown = changed.slice(0, MAX_ITEMS).map(u => {\n const name = safe(u.name);\n const ch = u.changes || {};\n const keys = Object.keys(ch);\n\n // make a short per-user change summary\n const parts = keys.map(k => {\n const v = ch[k];\n if (v && typeof v === \"object\" && (\"old\" in v || \"new\" in v)) return k;\n if (v && typeof v === \"object\" && (\"added\" in v || \"removed\" in v)) return k;\n return k;\n });\n\n return parts.length ? `${name} (${parts.join(\", \")})` : name;\n });\n\n const more = changed.length - shown.length;\n return `Users changed: ${shown.join(\"; \")}${more > 0 ? ` … (+${more} more)` : \"\"}`;\n}\n\nfunction formatChangedServices(changed) {\n changed = arr(changed);\n if (changed.length === 0) return \"\";\n\n const shown = changed.slice(0, MAX_ITEMS).map(svc => {\n const unit = safe(svc.unit);\n const changes = svc.changes || {};\n const keys = Object.keys(changes);\n return keys.length ? `${unit} (${keys.join(\", \")})` : unit;\n });\n\n const more = changed.length - shown.length;\n return `Services changed: ${shown.join(\"; \")}${more > 0 ? ` … (+${more} more)` : \"\"}`;\n}\n\nfunction formatPkgVersionChanged(changed) {\n changed = arr(changed);\n if (changed.length === 0) return \"\";\n\n const shown = changed.slice(0, MAX_ITEMS).map(x => {\n const pkg = safe(x.package);\n const oldv = safe(x.old);\n const newv = safe(x.new);\n if (!pkg) return \"\";\n if (oldv || newv) return `${pkg} (${oldv} → ${newv})`;\n return pkg;\n }).filter(Boolean);\n\n const more = changed.length - shown.length;\n return `Packages upgraded/downgraded: ${shown.join(\"; \")}${more > 0 ? ` … (+${more} more)` : \"\"}`;\n}\n\nfunction metaInline(f) {\n const role = safe(f.role);\n const reason = safe(f.reason);\n const parts = [];\n if (reason) parts.push(reason);\n if (role) parts.push(role);\n return parts.length ? ` (${parts.join(\", \")})` : \"\";\n}\n\nfunction changeInline(f) {\n const ch = f.changes || {};\n const parts = [];\n\n // show role/reason changes explicitly when present\n if (ch.role && ((\"old\" in ch.role) || (\"new\" in ch.role))) {\n parts.push(`role: ${safe(ch.role.old)} → ${safe(ch.role.new)}`);\n }\n if (ch.reason && ((\"old\" in ch.reason) || (\"new\" in ch.reason))) {\n parts.push(`reason: ${safe(ch.reason.old)} → ${safe(ch.reason.new)}`);\n }\n\n // keep your existing keys list, but don’t duplicate role/reason\n const keys = Object.keys(ch).filter(k => k !== \"role\" && k !== \"reason\");\n if (keys.length) parts.push(keys.map(k => (k === \"content\" ? \"content\" : k)).join(\", \"));\n\n return parts.length ? ` (${parts.join(\"; \")})` : \"\";\n}\n\nfunction formatFiles(list, label) {\n list = arr(list);\n if (list.length === 0) return \"\";\n\n const shown = list.slice(0, MAX_ITEMS).map(f => {\n const path = safe(f.path);\n return path ? `${path}${metaInline(f)}` : \"\";\n }).filter(Boolean);\n\n const more = list.length - shown.length;\n let line = `${label}: ${shown.join(\", \")}`;\n if (more > 0) line += ` … (+${more} more)`;\n return line;\n}\n\nfunction formatFilesChanged(changed) {\n changed = arr(changed);\n if (changed.length === 0) return \"\";\n\n const shown = changed.slice(0, MAX_ITEMS).map(f => {\n const path = safe(f.path);\n return path ? `${path}${changeInline(f)}` : \"\";\n }).filter(Boolean);\n\n const more = changed.length - shown.length;\n return `Files changed: ${shown.join(\"; \")}${more > 0 ? ` … (+${more} more)` : \"\"}`;\n}\n\nfunction trimToMax(text) {\n if (text.length <= MAX_CHARS) return text;\n return text.slice(0, MAX_CHARS - 20).trimEnd() + \"\\n…(truncated)\";\n}\n\n// Header bits (host/time if present)\nconst host = r.new?.host || r.old?.host || \"\";\nconst ts = r.generated_at || \"\";\nconst header = `enroll diff${host ? \" @ \" + host : \"\"}${ts ? \" (\" + ts + \")\" : \"\"}`;\n\n// Build message lines\nconst lines = [header];\n\n// Packages\nlines.push(formatList(\"Packages added\", r.packages?.added));\nlines.push(formatList(\"Packages removed\", r.packages?.removed));\nlines.push(formatPkgVersionChanged(r.packages?.version_changed));\n\n// Services\nlines.push(formatList(\"Services enabled +\", r.services?.enabled_added));\nlines.push(formatList(\"Services enabled -\", r.services?.enabled_removed));\nlines.push(formatChangedServices(r.services?.changed));\n\n// Users\nlines.push(formatList(\"Users added\", r.users?.added));\nlines.push(formatList(\"Users removed\", r.users?.removed));\nlines.push(formatChangedUsers(r.users?.changed));\n\n// Files\nlines.push(formatFiles(r.files?.added, \"Files added\"));\nlines.push(formatFiles(r.files?.removed, \"Files removed\"));\nlines.push(formatFilesChanged(r.files?.changed));\n\n// Clean + join\nconst msgText = lines.filter(s => safe(s).trim().length > 0).join(\"\\n\");\n\n// Set payload for downstream node\nmsg.payload = trimToMax(msgText);\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 630, + "y": 300, + "wires": [ + [ + "6bb5aa95e1791737", + "fbfd6dacc8a6339d" + ] + ] + }, + { + "id": "6bb5aa95e1791737", + "type": "http response", + "z": "d84e1772e5d4cb01", + "g": "90a4098e08d188cc", + "name": "Response", + "statusCode": "201", + "headers": {}, + "x": 820, + "y": 240, + "wires": [] + }, + { + "id": "5778bcd1e4ff8e60", + "type": "comment", + "z": "d84e1772e5d4cb01", + "name": "Remember to set X-Enroll-Secret as an environment variable in your flow, with a secret value that matches what enroll sends with --webhook-header !", + "info": "", + "x": 710, + "y": 140, + "wires": [] + }, + { + "id": "fbfd6dacc8a6339d", + "type": "debug", + "z": "d84e1772e5d4cb01", + "g": "90a4098e08d188cc", + "name": "Debug or send onward to Slack, Signal, etc", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "targetType": "msg", + "statusVal": "", + "statusType": "auto", + "x": 930, + "y": 340, + "wires": [] + } +] ``` \ No newline at end of file