diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u6-deploy-workflow-infrastructure-design-plan.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u6-deploy-workflow-infrastructure-design-plan.md new file mode 100644 index 0000000..2419792 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u6-deploy-workflow-infrastructure-design-plan.md @@ -0,0 +1,219 @@ +# Infrastructure Design Plan — U6 Deploy Workflow + +## Prerequisite note (deliberate deviation) + +Functional Design and NFR Design are **skipped** for U6 per the execution plan (`unit-of-work.md`, +`aidlc-state.md`) — U6 is a Pipeline-type unit (YAML, no C# logic), and its behavioural contract is +already fully specified by FR-02, FR-03, FR-04, FR-06, FR-08, FR-20, ASM-01, ASM-03, ASM-04, D-02, +D-05, D-09, D-26, D-27 and the U5↔U6 shared-interface constraint in +`unit-of-work-dependency.md` ("U5 with U6 — one interface, two files"). Infrastructure Design is the +right entry point: it maps that contract onto an actual host layout. + +## Steps + +- [x] Step 1: Analyze design artifacts (requirements.md §§ Decisions/FR/ASM, unit-of-work.md U6, + unit-of-work-dependency.md, reference project `.gitea/workflows/` at + `K:\Development\SlpSoftware\Projects\SlpSoftware`) +- [x] Step 2: Create this plan +- [x] Step 3: Generate context-appropriate questions (below) +- [x] Step 4: Store plan (this file) +- [x] Step 5: Notify user in chat +- [x] Step 6: Collect and analyze answers (12/12 answered, no contradictions — Q1/Q10 initially + looked like a conflict, same-port two-instance clash, resolved: port assignment is a + host/nginx-level fact outside the workflow's concern, so "directory-only" split is consistent) +- [x] Step 7: Generate infrastructure design artifacts (`infrastructure-design.md`, + `deployment-architecture.md`) +- [x] Step 8: Present completion message +- [ ] Step 9: Wait for explicit approval +- [ ] Step 10: Record approval and update progress + +## What the reference project (`SlpSoftware`) already establishes — not re-asked + +- Reusable `workflow_call` deploy workflow, invoked with `secrets: inherit` +- `sshpass` + `scp` in a plain shell step (container SCP actions fail on the Podman runner with a 409 + attach error) — D-05 +- A `config` job that turns `env:` values into job outputs, because the `env` context is unavailable + inside a reusable workflow's `with:` block +- Gitea Actions **variables** for paths/environment names, **secrets** for host/credentials + (`PI_MAIN_HOST`, `PI_MAIN_PORT`, `PI_MAIN_USERNAME`, `PI_MAIN_PASSWORD` — reusable per ASM-07) + +None of that needs to be re-decided. What the reference project does **not** cover — because it only +ever uploads a static `dist/` folder to a fixed path — is everything below: this unit publishes a +running .NET process, must switch releases atomically without a moment of downtime for the +customer's website, must back up a database first, and must be able to restart the process and prove +it came back healthy. + +--- + +## Category: Deployment Environment + +### Question 1 +Is production the **same Raspberry Pi** as test (different port/directory), or a **second, +separate** Pi? + +A) Same Pi, different port and directory per environment +B) Separate Pi per environment +C) Not decided yet — design for either (parameterize host via Gitea variables/secrets, don't assume) + +[Answer]: A, but only a different directory + +### Question 2 +What OS architecture is the Pi running, for the `dotnet publish` runtime identifier? + +A) `linux-arm64` (Raspberry Pi 4/5, 64-bit OS) +B) `linux-arm` (32-bit OS) +C) `linux-x64` (not a Pi / emulated) +D) Other (please describe after [Answer]: tag below) + +[Answer]: A, but I might need a windows package later as well for other client API's. For now it is on the Pi + +--- + +## Category: Compute Infrastructure + +### Question 3 +How is the published process managed/restarted on the Pi (ASM-03 assumes systemd)? + +A) systemd service, one unit per environment (e.g. `slpmodularcms-test.service`, + `slpmodularcms-production.service`), restarted via `sudo systemctl restart ` over SSH +B) systemd service, but restart is a manual/documented step, not automated in the workflow +C) Some other process manager (pm2, supervisor, a custom script) — describe below +D) Other (please describe after [Answer]: tag below) + +[Answer]: A, but if there are better or more convenient ways to manage .net processes let me know + +### Question 4 +Passwordless `sudo systemctl restart` for the deploy user — is that already configured, or does it +need to be part of the one-time host setup documented in Operations? + +A) Already configured +B) Needs to be added — document it as a one-time host-setup step in Operations +C) Avoid sudo entirely — run the app as the deploy user's own systemd **user** service + (`systemctl --user restart`), no elevation needed +D) Other (please describe after [Answer]: tag below) + +[Answer]: C + +### Question 5 +Should the workflow **publish** (`dotnet publish -r --self-contained ...`) framework-dependent +or self-contained? + +A) Self-contained (Pi doesn't need a separately-installed .NET runtime; larger artifact) +B) Framework-dependent (assumes .NET 10 runtime already installed on the Pi, per ASM-03; smaller + artifact) +C) Other (please describe after [Answer]: tag below) + +[Answer]: B + +--- + +## Category: Storage Infrastructure + +### Question 6 +Release directory layout on the host, given `deploy_path` as the single input (FR-02) — how should +`releases/`, the atomic `current` pointer, and the persistent `wwwroot/web/` be arranged underneath +it? + +A) `{deploy_path}/releases/{timestamp-or-sha}/` per release, `{deploy_path}/current` symlink switched + atomically to point at one release, `{deploy_path}/shared/wwwroot-web/` persistent and + symlinked into each new release as `wwwroot/web` +B) Same as A, but the persistent website content lives at a path **outside** `deploy_path` entirely + (e.g. a fixed host path unrelated to the deploy target), to make it structurally impossible for + any future workflow change to prune it by mistake +C) Other (please describe after [Answer]: tag below) + +[Answer]: A + +### Question 7 +How many previous releases should be retained after pruning (FR-06: "at least the previous one")? + +A) 2 (current + 1 previous) +B) 3 (current + 2 previous) +C) 5 +D) Other (please describe after [Answer]: tag below) + +[Answer]: A + +### Question 8 +Database backup before production deploy (FR-20) — is it automatable from the Gitea runner over SSH +(ASM-04 assumes the database is reachable from the *application*, not necessarily from the runner)? + +A) Automatable: the deploy workflow runs a backup command over SSH on the Pi itself (e.g. + `sqlcmd`/`sqlpackage` or a SQL Server backup script already on the host), before the atomic switch +B) Not automatable from the runner: emit a clear, verifiable manual step in the deployment + instructions instead (FR-20's documented fallback), and the workflow does not attempt it +C) Not sure yet — design the workflow with an explicit backup step that can be a placeholder script + the user fills in during Operations +D) Other (please describe after [Answer]: tag below) + +[Answer]: A + +--- + +## Category: Networking Infrastructure + +### Question 9 +Post-switch health verification (FR-06 implies confirming the restart succeeded) — how should the +workflow check `/health`? + +A) `curl` from the Gitea runner against the environment's public HTTPS URL +B) `curl` executed **on the Pi itself** over the same SSH connection, against `localhost:` + (works even if the public URL is only reachable through a reverse proxy not yet configured, and + doesn't depend on external DNS/TLS) +C) Both — localhost check on the Pi first, then a public-URL check as a secondary confirmation +D) Other (please describe after [Answer]: tag below) + +[Answer]: A + +### Question 10 +Is there an existing reverse proxy (nginx, as in the reference project) in front of this +application, or does Kestrel serve requests directly? + +A) Kestrel serves directly on a fixed port per environment (e.g. `5000` test, `5001` production) — + no reverse proxy for this app +B) Existing nginx reverse proxy in front, forwarding to Kestrel on a local port +C) Not decided yet / out of scope for this workflow — document the assumption and let Operations + confirm the real topology +D) Other (please describe after [Answer]: tag below) + +[Answer]: B + +--- + +## Category: Monitoring Infrastructure + +### Question 11 +If the post-switch health check (Question 9) fails, what should the workflow do? + +A) Fail the job loudly (red pipeline) but leave the atomic switch as-is — no automatic rollback; + manual rollback via Operations' documented procedure +B) Automatically re-point `current` back to the previous release and restart, then fail the job +C) Other (please describe after [Answer]: tag below) + +[Answer]: A + +--- + +## Category: Shared Infrastructure + +### Question 12 +Gitea Actions variable/secret naming for the deploy target — reuse the reference project's +`PI_MAIN_*` secrets (ASM-07), or introduce project-specific names since this repo has its own +test **and** production paths on the same or a different host? + +A) Reuse `PI_MAIN_HOST` / `PI_MAIN_PORT` / `PI_MAIN_USERNAME` / `PI_MAIN_PASSWORD` as-is (same + values already configured for the reference repo, since it's the same Pi) +B) New, project-scoped secrets (e.g. `SLPMODULARCMS_PI_HOST`, etc.) even if the values happen to be + identical today, so the two repositories' deploy targets can diverge independently later +C) Other (please describe after [Answer]: tag below) + +[Answer]: A, but rename PI_MAIN_HOST to PI_MAIN_ADDRESS + +--- + +## Self-Validation + +- [x] Every question uses lettered multiple-choice options +- [x] Every question ends in an explicit `Other` option +- [x] Every question has an `[Answer]:` tag +- [x] No process/approval question is included here (those stay in chat) diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u6-deploy-workflow/infrastructure-design/deployment-architecture.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u6-deploy-workflow/infrastructure-design/deployment-architecture.md new file mode 100644 index 0000000..37e5566 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u6-deploy-workflow/infrastructure-design/deployment-architecture.md @@ -0,0 +1,137 @@ +# Deployment Architecture — U6 Deploy Workflow + +## Overview Diagram + +```mermaid +graph TD + ci["CI Workflow U5"] + deploy["Reusable Deploy Workflow deploy-scp.yaml"] + runner["Gitea Runner"] + ssh["SSH SCP Transport"] + pi["Raspberry Pi"] + svc_test["systemd --user slpmodularcms-test"] + svc_prod["systemd --user slpmodularcms-production"] + rel_test["Test Releases Directory"] + rel_prod["Production Releases Directory"] + shared["Persistent wwwroot-web"] + nginx["Existing nginx Reverse Proxy"] + db["SQL Server Database"] + + ci -->|"invokes with inputs"| deploy + deploy -->|"runs on"| runner + runner -->|"backup then upload over"| ssh + ssh --> pi + pi --> rel_test + pi --> rel_prod + rel_test -.->|"symlink"| shared + rel_prod -.->|"symlink"| shared + pi --> svc_test + pi --> svc_prod + svc_test --> nginx + svc_prod --> nginx + ssh -->|"production only backup"| db + runner -->|"health check via public URL"| nginx + + classDef pipeline fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef transport fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef host fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef storage fill:#fbd38d,stroke:#92400e,stroke-width:1px,color:#000; + classDef external fill:#e9d5ff,stroke:#6b21a8,stroke-width:1px,color:#000; + + class ci,deploy,runner pipeline; + class ssh transport; + class pi,svc_test,svc_prod host; + class rel_test,rel_prod,shared,db storage; + class nginx external; +``` + +Text alternative: U5 invokes the reusable deploy workflow on the Gitea runner, which backs up the +database (production only) then uploads the release to the Pi over SSH/SCP; the Pi hosts separate +release directories and systemd `--user` services for test and production, both symlinking the same +persistent website content and sitting behind the existing nginx reverse proxy, which the runner +also calls to verify `/health` after each switch. + +--- + +## Release Directory Diagram + +```mermaid +graph TD + deploy_path["deploy_path per environment"] + releases["releases directory"] + rel_new["releases timestamp new"] + rel_prev["releases timestamp previous kept"] + current["current symlink"] + shared_dir["shared directory"] + web_persist["shared wwwroot-web persistent"] + admin["wwwroot admin part of release"] + web_link["wwwroot web symlink"] + + deploy_path --> releases + deploy_path --> current + deploy_path --> shared_dir + releases --> rel_new + releases --> rel_prev + shared_dir --> web_persist + current -.->|"points to"| rel_new + rel_new --> admin + rel_new --> web_link + web_link -.->|"symlink"| web_persist + + classDef path fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef release fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef persistent fill:#fbd38d,stroke:#92400e,stroke-width:1px,color:#000; + + class deploy_path,releases,current path; + class rel_new,rel_prev,admin release; + class shared_dir,web_persist,web_link persistent; +``` + +Text alternative: under each environment's deploy path, the current symlink points at the newest of +two retained release directories, each containing the rebuilt admin SPA and a symlink into the one +persistent shared website directory that survives every release. + +--- + +## Deployment Sequence Diagram + +```mermaid +sequenceDiagram + box rgba(99,179,237,0.4) Pipeline + participant U5 as CI Workflow + participant DW as Deploy Workflow + end + box rgba(154,230,180,0.4) Pi + participant PI as Pi Host + participant SVC as Systemd Service + end + box rgba(233,213,255,0.4) External + participant NGINX as Nginx Proxy + participant DB as Database + end + + U5->>DW: invoke with artifact_name, environment, deploy_path, service_name, health_check_url + alt production only + DW->>PI: run backup script over SSH + PI->>DB: BACKUP DATABASE + DB-->>PI: backup file written + end + DW->>PI: scp artifact into new release directory + DW->>PI: link persistent web content into release + DW->>PI: switch current symlink atomically + DW->>SVC: restart service + SVC-->>DW: restart acknowledged + DW->>NGINX: curl health check url + NGINX->>SVC: forward to local port + SVC-->>NGINX: health response + NGINX-->>DW: health check result + alt health check passed + DW->>PI: prune releases beyond retention count + else health check failed + DW-->>U5: fail job, current left on new release + end +``` + +Text alternative: the deploy workflow optionally backs up the database, uploads and links the new +release, atomically switches and restarts the service, then verifies health through nginx before +pruning old releases — or fails the job without rolling back if the health check does not pass. diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u6-deploy-workflow/infrastructure-design/infrastructure-design.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u6-deploy-workflow/infrastructure-design/infrastructure-design.md new file mode 100644 index 0000000..edca9c6 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u6-deploy-workflow/infrastructure-design/infrastructure-design.md @@ -0,0 +1,185 @@ +# Infrastructure Design — U6 Deploy Workflow + +**Date**: 2026-07-28 +**Basis**: `u6-deploy-workflow-infrastructure-design-plan.md` (12 questions, all answered) + +--- + +## 1. Host Topology + +**Single Raspberry Pi** (`linux-arm64`) hosts both **test** and **production** — same machine, same +port per environment is not something this workflow manages, only the **directory differs** +(Q1 = A, "but only a different directory"). + +This is coherent with Q10 = B (an **existing nginx reverse proxy** already sits in front of the +application): nginx routes by hostname to a **fixed, pre-configured local port per environment**. +That port assignment is a one-time host-setup fact (documented in Operations, FR-23) — the deploy +workflow never reads, writes or reasons about a port. Its only environment-specific inputs are the +deploy path and which systemd unit to restart. + +**Publish target**: `linux-arm64`, **framework-dependent** (Q2 = A, Q5 = B) — assumes the .NET 10 +runtime is already installed on the Pi (ASM-03). Smaller artifact, faster upload over the same SSH +transport every other unit already relies on. + +> **Noted, not built**: a Windows-hosted client API may be needed later for other purposes (per +> Q2's answer). Out of scope for U6 — if it materializes, it is a second `workflow_call` with its +> own RID and transport, following the same extensibility pattern FR-02/D-02 already established for +> FTPS. No design decision here needs to anticipate it further. + +--- + +## 2. Process Management + +**`systemctl --user` units**, one per environment (Q3 = A, refined by Q4 = C: avoid `sudo` +entirely). No elevation is needed on the deploy connection — the SSH user *is* the service owner. + +**INFRA-U6-01 (new finding, not previously raised in Requirements or Application Design)**: a +`systemd --user` service manager is torn down when the owning user's last login session ends — +which includes the SSH session the deploy workflow opens and closes on every run. Without +`loginctl enable-linger ` set **once** on the Pi, the freshly restarted service would +be killed again a few seconds after the deploy workflow's SSH connection closes, silently taking +test or production down right after every successful deploy. This is a one-time host-setup item, +not a per-run workflow step — it must be carried into Operations' deployment instructions (FR-23) +as an explicit prerequisite, verified once and never touched by the workflow itself. + +Unit naming: `slpmodularcms-test.service` / `slpmodularcms-production.service`, supplied to the +reusable workflow via a `service_name` input (see § 5) rather than hardcoded, so the names stay +data, not workflow logic. + +Restart command: `systemctl --user restart ` over the same SSH connection used for +the file transfer. + +--- + +## 3. Release and Persistence Layout + +Directory layout under each environment's `deploy_path` (Q6 = A): + +``` +{deploy_path}/ + releases/ + {timestamp}/ # one per deploy, e.g. 20260728143000 + ...published app files... + wwwroot/ + admin/ # part of the release — rebuilt by BuildAndCopyAdminFrontend on every publish + web -> ../../../shared/wwwroot-web # symlink, NOT part of the release + shared/ + wwwroot-web/ # persistent customer website content — survives every release (FR-08, ASM-01) + current -> releases/{timestamp} # atomic switch target; systemd unit's WorkingDirectory/ExecStart points here +``` + +**Retention (Q7 = A)**: keep **2** releases — `current` plus exactly one previous. Pruning runs +**after** a successful health check (§ 4), deleting every `releases/*` entry except the two most +recent by directory name (timestamps sort lexically). `shared/` is never touched by pruning. + +**wwwroot/web handling**: `dotnet publish` produces an (empty or placeholder-only) `wwwroot/web/` +inside the release per the existing `.csproj`. Before the atomic switch, the deploy step must: +1. `mkdir -p {deploy_path}/shared/wwwroot-web` (idempotent — safe on the very first deploy, when + nothing has been placed there yet, per U1's tolerance for a missing `wwwroot/web` at startup) +2. Remove whatever `wwwroot/web` the publish step produced inside the new release directory +3. Symlink `releases/{timestamp}/wwwroot/web -> ../../../shared/wwwroot-web` + +Only then is `current` re-pointed. This ordering is load-bearing: symlinking after the switch would +leave a window where `current` serves a release with no `web` mount at all. + +--- + +## 4. Deployment Sequence and Health Verification + +Per-environment sequence (test runs steps 1, 3–7; production additionally runs step 2): + +1. Download the build artifact (`actions/download-artifact`, unchanged from the reference project) +2. **Production only** (Q8 = A): trigger a database backup over the existing SSH connection — + `ssh {user}@{host} 'bash ~/scripts/backup-slpmodularcms-db.sh'`. The backup script itself lives + on the Pi and is created once during host setup (Operations, FR-23); the workflow never + transmits or references database credentials, keeping D-16's "runtime secrets live in host + environment variables, the workflow does not manage them" intact. This satisfies FR-20's + automation branch without adding a new secret surface. +3. Upload the artifact via `sshpass` + `scp` into a fresh `releases/{timestamp}/` directory + (unchanged transport pattern from the reference project, D-05) +4. Link the persistent website (§ 3, steps 1–3 above) +5. Atomically switch `current` to the new release (`ln -sfn` — atomic on the same filesystem) and + restart the environment's systemd `--user` unit +6. **Verify** (Q9 = A): `curl -f` from the **Gitea runner** against the environment's public HTTPS + `/health` URL — reachable because nginx (Q10 = B) already routes that hostname to the + now-restarted local port. Retry with a short backoff (e.g. up to 10 attempts, 3s apart) to + absorb ordinary process-restart time before declaring failure. +7. Prune old releases per § 3 retention rule — **only if step 6 succeeded** + +**On health-check failure (Q11 = A)**: the job fails loudly; `current` is **left pointed at the new, +unhealthy release** — no automatic rollback. Because retention always keeps the previous release on +disk, a manual rollback is always a re-point-and-restart away: documented as an explicit Operations +procedure (FR-23, D-26), not automated here. Pruning is skipped in this case, since the failed +release must not be the only one left standing. + +--- + +## 5. Reusable Workflow Interface (shared with U5) + +`deploy-scp.yaml` (`workflow_call`) inputs — extends FR-02's minimum set: + +| Input | Type | Required | Purpose | +|---|---|---|---| +| `artifact_name` | string | yes | Build artifact to download (FR-02 minimum) | +| `environment` | string | yes | Label used in log output and health-check selection (FR-02 minimum) | +| `deploy_path` | string | yes | Environment's release root, e.g. `${{ vars.DEPLOY_PATH_TEST }}` (FR-02 minimum) | +| `service_name` | string | yes | systemd `--user` unit to restart, e.g. `${{ vars.SERVICE_NAME_TEST }}` | +| `health_check_url` | string | yes | Public `/health` URL to verify, e.g. `${{ vars.HEALTH_CHECK_URL_TEST }}` | +| `run_db_backup` | boolean | no, default `false` | Set `true` only for the production call (FR-20 gate) | +| `transport` | string | no, default `scp` | Reserved per D-02/NFR-09; only `scp` implemented now | + +`secrets: inherit` passes through the shared Pi credentials (§ 6) unchanged from the reference +project's pattern. + +--- + +## 6. Gitea Variables and Secrets + +**Secrets** (shared — same Pi for both environments, Q1 + Q12): + +| Secret | Notes | +|---|---| +| `PI_MAIN_ADDRESS` | Renamed from the reference project's `PI_MAIN_HOST` per Q12's answer | +| `PI_MAIN_PORT` | SSH port, unchanged convention | +| `PI_MAIN_USERNAME` | Deploy user — the same account owning the `--user` systemd units | +| `PI_MAIN_PASSWORD` | Unchanged convention (D-16; SSH-key migration remains a documented future step per the reference project's own note) | + +**Variables** (per environment): + +| Variable | Test | Production | +|---|---|---| +| `DEPLOY_PATH_TEST` / `DEPLOY_PATH_PRODUCTION` | e.g. `/home/{user}/apps/slpmodularcms-test` | e.g. `/home/{user}/apps/slpmodularcms-production` | +| `SERVICE_NAME_TEST` / `SERVICE_NAME_PRODUCTION` | `slpmodularcms-test.service` | `slpmodularcms-production.service` | +| `HEALTH_CHECK_URL_TEST` / `HEALTH_CHECK_URL_PRODUCTION` | e.g. `https://test./health` | e.g. `https:///health` | + +Exact hostnames/paths are filled in during Operations host setup (FR-23) — this design fixes the +**names and shapes** of the variables, not their runtime values. + +--- + +## 7. Decisions Traceability + +| Decision | Source | Resolution | +|---|---|---| +| Same Pi, directory-only split | Q1 = A | § 1 | +| `linux-arm64`, future Windows target noted only | Q2 = A | § 1 | +| `systemctl --user`, no sudo | Q3 = A, Q4 = C | § 2 | +| Framework-dependent publish | Q5 = B | § 1 | +| Release/shared/current layout | Q6 = A | § 3 | +| Retain 2 releases | Q7 = A | § 3 | +| DB backup via host script over SSH | Q8 = A | § 4 | +| Health check via public URL from the runner | Q9 = A | § 4 | +| Existing nginx reverse proxy | Q10 = B | § 1, § 4 | +| No automatic rollback on failed health check | Q11 = A | § 4 | +| `PI_MAIN_ADDRESS` (renamed), shared secrets | Q12 = A + rename | § 6 | +| **INFRA-U6-01**: `loginctl enable-linger` required for `--user` units to survive SSH disconnect | Newly identified during this design | § 2 — carried to Operations (FR-23) | + +--- + +## 8. What Remains for Code Generation + +- `.gitea/workflows/deploy-scp.yaml` implementing §§ 3–5 +- No application code changes — U6 is `.gitea/workflows/` only (component C-11) +- Verification in CI is necessarily limited to YAML validity and step logic review; the actual Pi, + SSH credentials and database are real-run concerns that belong to Operations (per U6's Definition + of Done in `unit-of-work.md`)