Designs where the deploy workflow puts things on the Pi

Infrastructure design for U6: single Pi split by directory not port,
systemd --user services instead of sudo, a releases/current/shared
layout that keeps the customer website outside every atomic switch,
and a health check through the existing reverse proxy before pruning
anything. Flags that user-level systemd needs enable-linger or the
service dies the moment the deploy SSH session closes.
This commit is contained in:
2026-07-28 15:32:51 +02:00
parent 247d8be241
commit 9a77eec0f1
3 changed files with 541 additions and 0 deletions
@@ -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.
@@ -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 <deploy-user>` 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 <service_name>` 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, 37; 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 13 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.<domain>/health` | e.g. `https://<domain>/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 §§ 35
- 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`)