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:
+137
@@ -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.
|
||||
+185
@@ -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, 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.<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 §§ 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`)
|
||||
Reference in New Issue
Block a user