Feature/gitea deployment workflow #1

Merged
Sluijsens merged 35 commits from feature/gitea-deployment-workflow into master 2026-07-29 16:50:44 +02:00
3 changed files with 541 additions and 0 deletions
Showing only changes of commit 9a77eec0f1 - Show all commits
@@ -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 <unit>` 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 <RID> --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:<port>`
(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)
@@ -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`)