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:
2026-10-15 19:51:36 +11:00
parent 9cbfa7db9b
commit 7d367f7712
6 changed files with 316 additions and 53 deletions

View File

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

View 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"

View File

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