Phase 7a: use the n8n SSH node (Execute Command is disabled in n8n 2.x)
n8n 2.x disables n8n-nodes-base.executeCommand by default (nodes.exclude), so the first workflow shape could not activate: 'Unrecognized node type'. Replace the Execute Command node with the SSH node, which is enabled, and keep the restricted trigger key: the server forced command runs exactly scripts/morning-brief.sh. - deploy/n8n-morning-brief.json: schedule + optional manual trigger -> SSH node -> Code check that makes a non-zero exit red (FR-12). - deploy/import-n8n-morning-brief.sh: builds the sshPrivateKey credential from the key (id schoolScanKey, mode-0600 temp, removed after import), imports, publishes, restarts once, and confirms activation in the n8n log. - scripts/morning-brief.sh: default IMAGE 0.6.1-phase8 so the scheduled job uses the deployed image. - docs/phase-7a-n8n-schedule.md, deploy/DEPLOYMENT.md: document the design and the throwaway-n8n rehearsal (activation + success + failure path).
This commit is contained in:
@@ -5,40 +5,55 @@ combined briefs, the Apprise channels, and eight real deliveries. This phase mak
|
||||
the schedule real: one n8n workflow that runs every day at 06:00 and sends the
|
||||
briefs.
|
||||
|
||||
## What changed
|
||||
## What the workflow is
|
||||
|
||||
`deploy/n8n-morning-brief.json` is now a two-node workflow:
|
||||
`deploy/n8n-morning-brief.json` is a four-node workflow:
|
||||
|
||||
```
|
||||
Schedule Trigger "0 6 * * *" (Australia/Melbourne)
|
||||
|
|
||||
v
|
||||
Execute Command ssh … sam@192.168.20.13 'scripts/morning-brief.sh'
|
||||
Schedule Trigger "0 6 * * *" (Australia/Melbourne) ─┐
|
||||
├─▶ SSH node ──▶ Code check
|
||||
Manual Trigger "Run now (manual)" ───────────────────┘ (restricted key)
|
||||
```
|
||||
|
||||
The earlier draft had three nodes (`scan` via `ssh remote` → `POST /notify/morning`).
|
||||
Two problems made that shape wrong here:
|
||||
- **SSH node** — authenticates to `.13` as `sam` with the restricted trigger key and
|
||||
runs the brief on the host. The scan needs node + patchright on `.13`, so it cannot
|
||||
run inside the n8n container.
|
||||
- **Code node** — the SSH node returns the remote exit code but does not fail on it.
|
||||
The Code node throws when `code !== 0`, so a failed scan or send turns the n8n
|
||||
execution red (FR-12).
|
||||
- **Manual Trigger** — optional. It lets Sam run the brief by hand from the n8n UI, or
|
||||
with `docker exec n8n n8n execute --id=schoolMorningBrf`. The schedule fires on its
|
||||
own; the manual trigger only does something when pressed.
|
||||
|
||||
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 the bounded command. It scans both children, builds the
|
||||
per-boy and combined briefs, sends them through Apprise, and exits non-zero when the
|
||||
scan or a send fails. The brief always opens with the failure when the scan failed —
|
||||
never "nothing due".
|
||||
|
||||
`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".
|
||||
The run needs **no** `SCHOOL_NOTIFY_TOKEN` in n8n. The token stays in
|
||||
`environment.d/school-service.env`, which the host script reads.
|
||||
|
||||
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.
|
||||
## Why not an Execute Command node
|
||||
|
||||
## The SSH trigger key
|
||||
The first design used one `n8n-nodes-base.executeCommand` node. **It cannot work on
|
||||
this instance.** n8n 2.x disables that node by default for security
|
||||
(`nodes.exclude = ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger']`).
|
||||
An imported workflow with it is marked active in the database but never activates:
|
||||
|
||||
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:
|
||||
```
|
||||
Activation of workflow "Compass / NHS — 6:00am morning brief" (schoolMorningBrf) did fail
|
||||
with error: "Unrecognized node type: n8n-nodes-base.executeCommand"
|
||||
```
|
||||
|
||||
The same error affects the pre-existing `Tree Info` workflow. The SSH node is enabled
|
||||
and, with the restricted key, runs exactly one command — so it is both workable and
|
||||
least-privilege. Enabling Execute Command instead would need `NODES_EXCLUDE=[]` in the
|
||||
shared n8n service, which re-enables arbitrary shell execution for every workflow.
|
||||
|
||||
## The SSH trigger key and credential
|
||||
|
||||
The n8n container mounts `/home/sam/.ssh` 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…
|
||||
@@ -48,27 +63,33 @@ restrict,command="cd /home/sam/paseo/projects/compass_teams_nhs && ./scripts/mor
|
||||
- `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.
|
||||
- The key is not Sam's personal key and is not used anywhere else.
|
||||
|
||||
Create it with:
|
||||
Create the key 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
|
||||
```
|
||||
|
||||
The SSH node needs the private key in an n8n credential (`sshPrivateKey`, id
|
||||
`schoolScanKey`). `deploy/import-n8n-morning-brief.sh` builds that credential from the
|
||||
key file into a mode-0600 temp file, imports it, and removes it. The key is never
|
||||
written into this repository.
|
||||
|
||||
## 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
|
||||
deploy/import-n8n-morning-brief.sh --apply # credential + workflow, 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`.
|
||||
The script imports the credential, copies the JSON into the container with
|
||||
`docker exec -i … sh -c 'cat > …'` (`docker cp` fails against the `~/.ssh` mount),
|
||||
runs `n8n import:workflow`, then `n8n publish:workflow --id=schoolMorningBrf` (n8n 2.x
|
||||
replaced `update:workflow --active`), restarts n8n once so the 06:00 schedule
|
||||
registers, and confirms the **n8n log** shows the workflow activated — the database's
|
||||
active flag alone is not proof.
|
||||
|
||||
The JSON carries a stable `id` (`schoolMorningBrf`). n8n 2.18.5 refuses an import
|
||||
whose workflow has no `id` (`SQLITE_CONSTRAINT: NOT NULL constraint failed:
|
||||
@@ -76,17 +97,26 @@ workflow_entity.id`), so the id is required, not cosmetic. Re-importing the same
|
||||
updates the same workflow instead of adding a second copy.
|
||||
|
||||
`n8n publish:workflow` prints *"Changes will not take effect if n8n is running"*, which
|
||||
is why the import script restarts n8n once. The full import → publish → active check
|
||||
was rehearsed in a throwaway n8n container with a temporary data directory before this
|
||||
plan was proposed; no shared instance was touched.
|
||||
is why the import script restarts n8n once. The full import → activate → run and the
|
||||
failure path were rehearsed in a throwaway n8n container with a temporary data
|
||||
directory, using the same n8n 2.18.5 image, before the change was proposed. The shared
|
||||
instance was not touched for the rehearsal.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
docker exec n8n n8n list:workflow --active=true # the workflow is published
|
||||
docker logs n8n | grep 'Activated workflow "Compass / NHS'
|
||||
docker exec n8n n8n list:workflow --active=true # schoolMorningBrf is listed
|
||||
docker exec compass-teams-nhs /app/school notify status
|
||||
```
|
||||
|
||||
Rehearsal evidence (throwaway container, same image):
|
||||
|
||||
- activation: `Activated workflow "school ssh rehearsal"` — no `Unrecognized node type`
|
||||
- success path (forced command echo): `status: success`
|
||||
- failure path (forced command `exit 7`): `status: error`, CLI exit 1, message
|
||||
`morning-brief.sh failed with exit 7`
|
||||
|
||||
A healthy morning looks like this in `notify status`:
|
||||
|
||||
```
|
||||
@@ -100,26 +130,24 @@ A healthy morning looks like this in `notify status`:
|
||||
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:
|
||||
The Schedule Trigger only fires at 06:00, so the schedule is proved by the activation
|
||||
log line 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>
|
||||
docker exec n8n n8n execute --id=schoolMorningBrf
|
||||
```
|
||||
|
||||
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.
|
||||
execute` starts from the Manual Trigger; both trigger paths reach the same SSH node.
|
||||
|
||||
## Not touched
|
||||
|
||||
- Compass / Teams: read-only. Nothing in this phase writes to the school.
|
||||
- Other n8n workflows: untouched.
|
||||
- n8n's global node configuration: untouched. Execute Command stays disabled.
|
||||
- Secrets: no address, phone number, Apprise key, session secret, or notify token is
|
||||
written into the workflow JSON, this document, or git.
|
||||
written into the workflow JSON, this document, or git. The SSH private key lives in
|
||||
an n8n credential and the host `authorized_keys`, never in the repository.
|
||||
|
||||
Reference in New Issue
Block a user