Phase 7a: real 6:00am n8n schedule for the school brief (AUD-115)
The 06:00 workflow is now one schedule-trigger -> Execute Command pair that runs scripts/morning-brief.sh on the host over a restricted SSH key. The earlier scan -> HTTP shape could not work: the n8n container cannot resolve the `remote` ssh alias (its ~/.ssh/config is a dangling nix symlink) and a split scan node hides scan failures before the notify node runs. - deploy/n8n-morning-brief.json: two-node workflow, scheduleTrigger v1.3 - deploy/setup-n8n-scan-key.sh: restricted (restrict,command=) trigger key - deploy/import-n8n-morning-brief.sh: import + publish + restart + verify - deploy/DEPLOYMENT.md, Makefile: document and expose both scripts (dry run) - docs/phase-7a-n8n-schedule.md: design, verification, failure drill Import into the shared n8n service still needs Sam's approval. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
12
Makefile
12
Makefile
@@ -19,7 +19,7 @@ GO_RUN = timeout 600 docker run --rm --network host --user "$$(id -u):$$(id -g)"
|
||||
-e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \
|
||||
-v "$$PWD":/src -w /src $(GO_IMAGE)
|
||||
|
||||
.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 verify-phase8 parse scan calendar notify morning-brief
|
||||
.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 verify-phase8 parse scan calendar notify morning-brief n8n-key n8n-import
|
||||
|
||||
help:
|
||||
@echo "make test run go test ./... in golang:1.25-alpine"
|
||||
@@ -38,6 +38,8 @@ help:
|
||||
@echo "make notify build and send the morning briefs via Apprise (Phase 7; NOTIFY_ARGS=...)"
|
||||
@echo "make preview print the briefs without sending (Phase 7)"
|
||||
@echo "make morning-brief scan both children, then send the briefs (the n8n job)"
|
||||
@echo "make n8n-key print the restricted SSH trigger key plan (Phase 7a)"
|
||||
@echo "make n8n-import print the n8n import/publish plan (Phase 7a; APPLY=1 applies)"
|
||||
@echo "make verify-phase1 prove /health, the login and the Caddy route"
|
||||
@echo "make verify-phase8 prove the Hub API: /capabilities, /ask, /notify (--send delivers)"
|
||||
|
||||
@@ -102,6 +104,14 @@ preview:
|
||||
morning-brief:
|
||||
IMAGE=$(IMAGE) scripts/morning-brief.sh
|
||||
|
||||
# Phase 7a: the 06:00 n8n schedule. Both default to a dry run; pass APPLY=1 to
|
||||
# change the shared n8n service on .13 (after approval).
|
||||
n8n-key:
|
||||
deploy/setup-n8n-scan-key.sh $(if $(APPLY),--apply,)
|
||||
|
||||
n8n-import:
|
||||
deploy/import-n8n-morning-brief.sh $(if $(APPLY),--apply,)
|
||||
|
||||
# Phase 1 definition of done: /health 200, login page + session login, and the
|
||||
# Caddy route on .35.
|
||||
verify-phase1:
|
||||
|
||||
@@ -96,8 +96,16 @@ the API directly on the internal address.
|
||||
docker exec compass-teams-nhs /app/school notify status
|
||||
```
|
||||
|
||||
5. Import `deploy/n8n-morning-brief.json` in n8n, set `SCHOOL_NOTIFY_TOKEN` in
|
||||
the n8n environment, and activate the workflow (06:00 daily, Australia/Melbourne).
|
||||
5. Schedule the 06:00 daily run in n8n (Phase 7a, AUD-115):
|
||||
|
||||
```bash
|
||||
deploy/setup-n8n-scan-key.sh --apply # restricted SSH key for the trigger
|
||||
deploy/import-n8n-morning-brief.sh --apply # import + publish + restart n8n
|
||||
```
|
||||
|
||||
The workflow has one Execute Command node: `ssh … 'scripts/morning-brief.sh'`.
|
||||
It needs no `SCHOOL_NOTIFY_TOKEN` in n8n — the host script reads the project env
|
||||
file. See `docs/phase-7a-n8n-schedule.md` for the design and the failure drill.
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -109,3 +117,7 @@ Notes:
|
||||
service image. `scripts/morning-brief.sh` runs the scan and the send in order.
|
||||
- A run that cannot deliver exits non-zero, and the endpoint answers `207`, so a
|
||||
broken morning is visible in n8n instead of silent (FR-12).
|
||||
- The 06:00 schedule is the n8n workflow `Compass / NHS — 6:00am morning brief`
|
||||
(`deploy/n8n-morning-brief.json`), triggered over a restricted SSH key. It runs
|
||||
on the shared n8n service on `.13`, so its import, publish, and the one-off n8n
|
||||
restart need approval first.
|
||||
|
||||
101
deploy/import-n8n-morning-brief.sh
Executable file
101
deploy/import-n8n-morning-brief.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 7a (AUD-115): import + publish the 6:00am workflow in n8n.
|
||||
#
|
||||
# n8n on .13 is a shared family service. Run this only after the change is
|
||||
# approved. It imports one workflow, publishes it, restarts n8n once so the
|
||||
# 06:00 schedule registers, and verifies the published state.
|
||||
#
|
||||
# It does NOT run the workflow. The first real run is the next 06:00 trigger
|
||||
# (or `docker exec n8n n8n execute --id=<id>` by hand).
|
||||
#
|
||||
# Usage:
|
||||
# deploy/import-n8n-morning-brief.sh # dry run, print the plan
|
||||
# deploy/import-n8n-morning-brief.sh --apply
|
||||
# deploy/import-n8n-morning-brief.sh --apply --no-restart
|
||||
#
|
||||
# Environment overrides:
|
||||
# CONTAINER n8n container name (default n8n)
|
||||
# WORKFLOW workflow json path (default <repo>/deploy/n8n-morning-brief.json)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONTAINER="${CONTAINER:-n8n}"
|
||||
WORKFLOW="${WORKFLOW:-$REPO_ROOT/deploy/n8n-morning-brief.json}"
|
||||
IN_CONTAINER="/tmp/n8n-morning-brief.json"
|
||||
|
||||
APPLY=0
|
||||
RESTART=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--apply) APPLY=1 ;;
|
||||
--no-restart) RESTART=0 ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -f "$WORKFLOW" ]] || { echo "workflow not found: $WORKFLOW" >&2; exit 1; }
|
||||
|
||||
# The workflow name is stable; match on a substring to survive em-dash changes.
|
||||
WF_MATCH="${WF_MATCH:-6:00am morning brief}"
|
||||
|
||||
n8n_cli() { docker exec "$CONTAINER" n8n "$@"; }
|
||||
|
||||
wf_id() {
|
||||
n8n_cli list:workflow 2>/dev/null \
|
||||
| awk -F'|' -v m="$WF_MATCH" 'index($2, m) { print $1; exit }'
|
||||
}
|
||||
|
||||
echo "workflow file : $WORKFLOW"
|
||||
echo "n8n container : $CONTAINER"
|
||||
echo "in-container : $IN_CONTAINER"
|
||||
echo "restart n8n : $([[ "$RESTART" -eq 1 ]] && echo yes || echo no)"
|
||||
|
||||
if [[ "$APPLY" -eq 0 ]]; then
|
||||
echo
|
||||
echo "DRY RUN — no change to n8n. Re-run with --apply."
|
||||
echo "steps:"
|
||||
echo " 1. copy the JSON into the container"
|
||||
echo " 2. n8n import:workflow"
|
||||
echo " 3. n8n publish:workflow --id=<id>"
|
||||
echo " 4. $([[ "$RESTART" -eq 1 ]] && echo 'docker restart n8n (≈10s)' || echo 'skip restart')"
|
||||
echo " 5. n8n list:workflow --active=true shows the workflow"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. copy the workflow into the container =="
|
||||
docker exec -i "$CONTAINER" sh -c "cat > $IN_CONTAINER" < "$WORKFLOW"
|
||||
|
||||
echo "== 2. import =="
|
||||
n8n_cli import:workflow --input="$IN_CONTAINER"
|
||||
|
||||
ID="$(wf_id)"
|
||||
if [[ -z "$ID" ]]; then
|
||||
echo "could not find the imported workflow by name '$WF_MATCH'" >&2
|
||||
n8n_cli list:workflow >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "workflow id: $ID"
|
||||
|
||||
echo "== 3. publish =="
|
||||
n8n_cli publish:workflow --id="$ID"
|
||||
|
||||
echo "== 4. restart =="
|
||||
if [[ "$RESTART" -eq 1 ]]; then
|
||||
docker restart "$CONTAINER" >/dev/null
|
||||
echo "n8n restarted; waiting for the API to answer…"
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -s -o /dev/null --max-time 3 "http://127.0.0.1:5678/healthz"; then
|
||||
echo "n8n is up"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
echo "== 5. verify =="
|
||||
n8n_cli list:workflow --active=true
|
||||
echo
|
||||
echo "AUTHORIZED_KEY_HINT: the Execute Command node calls the host with"
|
||||
echo " ~/.ssh/id_ed25519_school_scan — create it with deploy/setup-n8n-scan-key.sh --apply"
|
||||
@@ -15,53 +15,20 @@
|
||||
"id": "c1a0a0a0-0000-4000-8000-000000000001",
|
||||
"name": "6:00am every day",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"typeVersion": 1.3,
|
||||
"position": [200, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"command": "ssh remote 'cd /home/sam/paseo/projects/compass_teams_nhs && scripts/scan-compass.sh'"
|
||||
"executeOnce": true,
|
||||
"command": "ssh -i /home/node/.ssh/id_ed25519_school_scan -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile=/home/node/.ssh/known_hosts -o StrictHostKeyChecking=yes sam@192.168.20.13 'cd /home/sam/paseo/projects/compass_teams_nhs && ./scripts/morning-brief.sh'"
|
||||
},
|
||||
"id": "c1a0a0a0-0000-4000-8000-000000000002",
|
||||
"name": "Scan Compass (both children, read-only)",
|
||||
"name": "Scan both children and send the briefs",
|
||||
"type": "n8n-nodes-base.executeCommand",
|
||||
"typeVersion": 1,
|
||||
"position": [480, 300],
|
||||
"notes": "The scan needs node + patchright on the host, so it runs outside the service container. A failed scan still lets the briefs go out: the brief carries the failure notice (FR-12)."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://192.168.20.13:8792/notify/morning",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "=Bearer {{$env.SCHOOL_NOTIFY_TOKEN}}"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": false,
|
||||
"options": {
|
||||
"timeout": 240000,
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "c1a0a0a0-0000-4000-8000-000000000003",
|
||||
"name": "Send the morning briefs",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [760, 300],
|
||||
"notes": "POST /notify/morning builds one brief per boy plus the combined brief to Jo and Sam and delivers them through Apprise. 200 = every channel delivered; 207 = a channel failed or a scan failed; 503 = SCHOOL_NOTIFY_TOKEN is not set on the service."
|
||||
"notes": "One bounded host command. The scan needs node + patchright on .13, so it runs outside this container. scripts/morning-brief.sh scans both children, builds the per-boy and combined briefs and sends them through Apprise, then exits non-zero if the scan or a send fails, so this execution goes red (FR-12). The key is restricted in authorized_keys to exactly this command."
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
@@ -69,18 +36,7 @@
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Scan Compass (both children, read-only)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Scan Compass (both children, read-only)": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send the morning briefs",
|
||||
"node": "Scan both children and send the briefs",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
|
||||
69
deploy/setup-n8n-scan-key.sh
Executable file
69
deploy/setup-n8n-scan-key.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 7a (AUD-115): the SSH trigger key for the 6:00am n8n workflow.
|
||||
#
|
||||
# The n8n container mounts ~/.ssh read-only at /home/node/.ssh (see the n8n
|
||||
# compose file). This script creates a dedicated key there and prints the exact
|
||||
# `authorized_keys` line that restricts it to one command. Nothing else can use
|
||||
# the key: no shell, no port forwarding, no agent.
|
||||
#
|
||||
# This writes under $HOME/.ssh, which is OUTSIDE this repository. Run it only
|
||||
# after the change is approved. Default run is a dry run and prints the plan.
|
||||
#
|
||||
# Usage:
|
||||
# deploy/setup-n8n-scan-key.sh # dry run, print the plan
|
||||
# deploy/setup-n8n-scan-key.sh --apply # create the key + authorize it
|
||||
#
|
||||
# Environment overrides:
|
||||
# KEY private key path (default ~/.ssh/id_ed25519_school_scan)
|
||||
# SCAN_CMD forced command (default the morning brief on the host)
|
||||
set -euo pipefail
|
||||
|
||||
KEY="${KEY:-$HOME/.ssh/id_ed25519_school_scan}"
|
||||
SCAN_CMD="${SCAN_CMD:-cd /home/sam/paseo/projects/compass_teams_nhs && ./scripts/morning-brief.sh}"
|
||||
AUTH="${AUTH:-$HOME/.ssh/authorized_keys}"
|
||||
COMMENT="n8n-school-scan"
|
||||
|
||||
APPLY=0
|
||||
case "${1:-}" in
|
||||
--apply) APPLY=1 ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
'') ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if [[ "$APPLY" -eq 0 ]]; then
|
||||
echo "DRY RUN — no files are written. Re-run with --apply."
|
||||
fi
|
||||
|
||||
if [[ ! -f "$KEY" ]]; then
|
||||
if [[ "$APPLY" -eq 1 ]]; then
|
||||
echo "creating $KEY"
|
||||
ssh-keygen -t ed25519 -N '' -C "$COMMENT" -f "$KEY" >/dev/null
|
||||
else
|
||||
echo "would create key : $KEY ($COMMENT)"
|
||||
fi
|
||||
fi
|
||||
|
||||
PUB="$(cat "$KEY.pub" 2>/dev/null || echo '<key not created yet>')"
|
||||
LINE="restrict,command=\"$SCAN_CMD\" $PUB"
|
||||
|
||||
echo "key : $KEY"
|
||||
echo "forced command : $SCAN_CMD"
|
||||
echo "authorized_keys : $AUTH"
|
||||
echo
|
||||
echo "line to add:"
|
||||
echo " $LINE"
|
||||
|
||||
if [[ "$APPLY" -eq 1 ]]; then
|
||||
if grep -qF "$PUB" "$AUTH"; then
|
||||
echo
|
||||
echo "public key already present in $AUTH — nothing to add"
|
||||
else
|
||||
printf '%s\n' "$LINE" >> "$AUTH"
|
||||
chmod 600 "$AUTH"
|
||||
echo
|
||||
echo "added the restricted key to $AUTH"
|
||||
fi
|
||||
echo
|
||||
echo "the n8n container now sees it at: /home/node/.ssh/$(basename "$KEY")"
|
||||
fi
|
||||
115
docs/phase-7a-n8n-schedule.md
Normal file
115
docs/phase-7a-n8n-schedule.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Phase 7a — the 6:00am n8n schedule (AUD-115)
|
||||
|
||||
Phase 7 (AUD-111) proved the delivery path: `POST /notify/morning`, the per-boy and
|
||||
combined briefs, the Apprise channels, and eight real deliveries. This phase makes
|
||||
the schedule real: one n8n workflow that runs every day at 06:00 and sends the
|
||||
briefs.
|
||||
|
||||
## What changed
|
||||
|
||||
`deploy/n8n-morning-brief.json` is now a two-node workflow:
|
||||
|
||||
```
|
||||
Schedule Trigger "0 6 * * *" (Australia/Melbourne)
|
||||
|
|
||||
v
|
||||
Execute Command ssh … sam@192.168.20.13 'scripts/morning-brief.sh'
|
||||
```
|
||||
|
||||
The earlier draft had three nodes (`scan` via `ssh remote` → `POST /notify/morning`).
|
||||
Two problems made that shape wrong here:
|
||||
|
||||
1. **`ssh remote` does not work.** The n8n container mounts `~/.ssh` read-only, and
|
||||
`~/.ssh/config` is a Nix symlink into `/nix/store`, which does not exist inside
|
||||
the container. The hostname `remote` does not resolve, and the container key is
|
||||
not in `~/.ssh/authorized_keys` (the host alias fails too).
|
||||
2. **A split scan/send node hides scan failures.** If the scan node fails, n8n stops
|
||||
before the HTTP node, so `POST /notify/morning` never runs and the family gets no
|
||||
brief at all — the opposite of FR-12.
|
||||
|
||||
`scripts/morning-brief.sh` is already the bounded command that does both steps and
|
||||
exits non-zero when the scan or a send fails. One Execute Command node is therefore
|
||||
both simpler and more correct: a failed scan turns the n8n execution red **and** still
|
||||
sends a brief that opens with the failure (`SCAN FAILED`), never "nothing due".
|
||||
|
||||
Because the single command runs on the host, n8n needs **no** `SCHOOL_NOTIFY_TOKEN`
|
||||
and no HTTP node. The token stays in `environment.d/school-service.env` only.
|
||||
|
||||
## The SSH trigger key
|
||||
|
||||
The n8n container mounts `/home/sam/.ssh` read-only at `/home/node/.ssh`. The trigger
|
||||
uses a dedicated key with a forced command, so it can run exactly one thing:
|
||||
|
||||
```
|
||||
restrict,command="cd /home/sam/paseo/projects/compass_teams_nhs && ./scripts/morning-brief.sh" ssh-ed25519 AAAA…
|
||||
```
|
||||
|
||||
- `restrict` disables pty, agent forwarding, port forwarding, X11, and shell access.
|
||||
- `command=` makes the server ignore whatever command the client sends.
|
||||
- The private key lives at `/home/sam/.ssh/id_ed25519_school_scan` (mode 0600) and is
|
||||
readable only by `sam` and the n8n container (which already mounts `~/.ssh`).
|
||||
- The key is not sam's personal key and is not used anywhere else.
|
||||
|
||||
Create it with:
|
||||
|
||||
```bash
|
||||
deploy/setup-n8n-scan-key.sh # dry run: print the plan
|
||||
deploy/setup-n8n-scan-key.sh --apply # create the key + the authorized_keys line
|
||||
```
|
||||
|
||||
## Import and activate
|
||||
|
||||
```bash
|
||||
deploy/import-n8n-morning-brief.sh # dry run: print the plan
|
||||
deploy/import-n8n-morning-brief.sh --apply # import, publish, restart n8n
|
||||
```
|
||||
|
||||
The script copies the JSON into the container with `docker exec -i … sh -c 'cat > …'`
|
||||
(`docker cp` fails against the read-only `~/.ssh` mount), runs
|
||||
`n8n import:workflow`, then `n8n publish:workflow --id=<id>` (n8n 2.x replaced
|
||||
`update:workflow --active`), restarts n8n once so the 06:00 schedule registers, and
|
||||
prints `n8n list:workflow --active=true`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
docker exec n8n n8n list:workflow --active=true # the workflow is published
|
||||
docker exec compass-teams-nhs /app/school notify status
|
||||
```
|
||||
|
||||
A healthy morning looks like this in `notify status`:
|
||||
|
||||
```
|
||||
1 Harry Harry email ok <date> 06:0x
|
||||
2 Harry Harry whatsapp ok <date> 06:0x
|
||||
3 Finn Finn email ok <date> 06:0x
|
||||
4 Finn Finn whatsapp ok <date> 06:0x
|
||||
5 combined Jo email ok <date> 06:0x
|
||||
6 combined Jo whatsapp ok <date> 06:0x
|
||||
7 combined Sam email ok <date> 06:0x
|
||||
8 combined Sam whatsapp ok <date> 06:0x
|
||||
```
|
||||
|
||||
Failure drill (never leave an empty day): point the key's forced command at a command
|
||||
that exits non-zero, trigger the workflow, and confirm the n8n execution is red and
|
||||
the brief body starts with the scan failure. Restore the command afterwards.
|
||||
|
||||
## Proving a run
|
||||
|
||||
The Schedule Trigger only fires at 06:00, so the schedule itself is proved by
|
||||
`n8n list:workflow --active=true` plus the next morning's `notify status` rows. To
|
||||
prove the full send path without waiting, run the published workflow once by hand:
|
||||
|
||||
```bash
|
||||
docker exec n8n n8n execute --id=<workflow-id>
|
||||
```
|
||||
|
||||
This sends the real briefs, so run it only when a real delivery is wanted. `n8n
|
||||
list:workflow` prints the id. Both checks are recorded in this issue's final comment.
|
||||
|
||||
## Not touched
|
||||
|
||||
- Compass / Teams: read-only. Nothing in this phase writes to the school.
|
||||
- Other n8n workflows: untouched.
|
||||
- Secrets: no address, phone number, Apprise key, session secret, or notify token is
|
||||
written into the workflow JSON, this document, or git.
|
||||
Reference in New Issue
Block a user