chore: init
This commit is contained in:
345
generic.md
Normal file
345
generic.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Project Architecture Preferences
|
||||
|
||||
Baseline architectural conventions for new projects. Claude Code should follow these defaults when scaffolding, implementing, or refactoring unless the project explicitly overrides them. When in doubt, ask before deviating.
|
||||
|
||||
These preferences are opinionated and have evolved from running real multi-site infrastructure. They optimise for: local-first operation, reproducible deployments, minimal release-time churn, and keeping secrets out of source control.
|
||||
|
||||
---
|
||||
|
||||
## 1. Workspace Layout
|
||||
|
||||
Projects are Rust cargo workspaces. The repository root contains:
|
||||
|
||||
```
|
||||
<repo-root>/
|
||||
├── Cargo.toml # workspace manifest (workspace-level deps + version)
|
||||
├── crates/ # all Rust crates live here
|
||||
│ ├── <app>-entities/ # domain types, DTOs, error enums — no I/O
|
||||
│ ├── <app>-core/ # business logic, pure where practical
|
||||
│ ├── <app>-data/ # data access: postgres, turso, filesystem, etc.
|
||||
│ ├── <app>-crypto/ # shared cryptography (only if needed across bins)
|
||||
│ ├── <app>-os-utils/ # shared OS/process/path helpers (only if needed)
|
||||
│ ├── <app>-api/ # binary: REST / JSON / WebSocket daemon
|
||||
│ ├── <app>-worker/ # binary: long-running processor / queue consumer
|
||||
│ └── <app>-cli/ # binary: operator / admin CLI
|
||||
├── <web/dashboard/ui>/ # Vite + React + SWC + TS frontend (when applicable)
|
||||
├── asset/ # deployment artifacts (see §6)
|
||||
├── script/ # deploy.sh and related operational scripts
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Crate naming
|
||||
Use `<app>-<role>` throughout. The `<app>` prefix makes grep, cargo output, and systemd unit naming unambiguous across a multi-project host.
|
||||
|
||||
### Separation of concerns (strict)
|
||||
- **entities** — types only. No I/O, no async runtime deps. Serde, thiserror, chrono/time are fine. Everything downstream depends on this.
|
||||
- **core** — business logic. Consumes entities. May define traits for data access (ports) that the `data` crate implements (adapters). No direct DB or network calls.
|
||||
- **data** — implements the traits defined in `core`. Owns all sqlx/turso/reqwest usage relevant to persistence and external services.
|
||||
- **binaries** — thin. Wire up config, logging, signal handling, and the appropriate core/data stack. Binaries should contain no business logic that could live in a library crate.
|
||||
|
||||
### Shared utility crates
|
||||
Only create `<app>-crypto`, `<app>-os-utils`, etc. when genuinely shared by two or more binaries. Premature extraction is worse than inlining — extract when the second consumer appears.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cargo Workspace Conventions
|
||||
|
||||
### Workspace-level versioning
|
||||
Every crate in the workspace shares a single version, defined once in the root `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "GPL-3.0-or-later" # adjust per project
|
||||
authors = ["Rob Thijssen <rob@example>"]
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
```
|
||||
|
||||
Each crate's `Cargo.toml` inherits:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "<app>-entities"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
```
|
||||
|
||||
**Rationale:** release tagging scripts only need to rewrite one version string. CI stamps and changelogs stay consistent across artifacts from the same tag.
|
||||
|
||||
### Workspace-level dependencies
|
||||
Declare every external dependency once under `[workspace.dependencies]` in the root manifest. Crates reference them via `dep.workspace = true`. This prevents version drift between crates in the same workspace.
|
||||
|
||||
```toml
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls", "macros", "migrate"] }
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
# ...
|
||||
```
|
||||
|
||||
### Internal crate dependencies
|
||||
Internal crates depend on each other via path + version:
|
||||
|
||||
```toml
|
||||
<app>-entities = { path = "../<app>-entities", version = "=0.1.0" }
|
||||
```
|
||||
|
||||
Use `=` pinning on the internal version to guarantee in-workspace coherence after publishing or vendoring.
|
||||
|
||||
### Edition and toolchain
|
||||
- Rust edition **2024**.
|
||||
- Commit a `rust-toolchain.toml` at the repo root pinning the stable channel to match CI.
|
||||
|
||||
---
|
||||
|
||||
## 3. Binaries and Runtime
|
||||
|
||||
### Daemons run under systemd
|
||||
API and worker binaries are managed by systemd unit files shipped from `asset/systemd/`. Binaries should:
|
||||
|
||||
- Log to stdout/stderr using `tracing` with structured JSON output when `JOURNAL_STREAM` is set (journald will ingest cleanly).
|
||||
- Read config from `/etc/<app>/config.toml` by default, overridable via `--config` and env vars (figment-style layering: file → env → CLI).
|
||||
- Handle `SIGTERM` gracefully: stop accepting new work, drain in-flight tasks with a bounded timeout, exit 0.
|
||||
- Never daemonise themselves. systemd owns the lifecycle.
|
||||
- Expose a health endpoint (for api) or emit periodic heartbeat logs (for workers) so `systemd` and monitoring can assess liveness.
|
||||
|
||||
### API crate
|
||||
- Axum is the default web framework unless there's a reason otherwise.
|
||||
- Serves REST + JSON over TCP, with WebSocket upgrades where streaming is needed.
|
||||
- TLS terminates at the site nginx reverse proxy (see §7) unless the binary itself is the ingress (e.g., Cichlid-style self-serving nodes), in which case use rustls with post-quantum-capable cipher suites.
|
||||
- API surface versioned under `/v1/` from day one.
|
||||
- Request/response types live in `<app>-entities` so clients (including the desktop app) can depend on them.
|
||||
|
||||
### Worker binaries
|
||||
- Long-running processors (ingestion, indexing, queue consumers) use Postgres `FOR UPDATE SKIP LOCKED` for work-claiming where a central DB is already in play.
|
||||
- Idempotent by design — crashes and restarts should never double-process.
|
||||
- Backoff with jitter on transient failures; escalate to DLQ semantics on repeated failure.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontends
|
||||
|
||||
### Web (default)
|
||||
Vite + React + SWC + TypeScript, in `<repo-root>/web/`:
|
||||
|
||||
```
|
||||
web/
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── index.html
|
||||
└── src/
|
||||
├── main.tsx
|
||||
├── App.tsx
|
||||
├── api/ # generated or hand-written client for the <app>-api
|
||||
├── components/
|
||||
├── routes/
|
||||
└── lib/
|
||||
```
|
||||
|
||||
- Build output is static. Deployed to an nginx CDN endpoint — no Node.js in production.
|
||||
- API base URL is configured at build time (Vite `import.meta.env.VITE_API_BASE_URL`) and stamped per environment during deploy.
|
||||
- Prefer React Query or equivalent for server state. Keep business logic server-side; the frontend is a rendering and interaction layer.
|
||||
|
||||
### Web (Rust framework exception)
|
||||
Use a Rust web framework (Axum + templating, or a fullstack framework) **only when** the deployment model requires a single self-contained binary with no external web server — e.g., distributed orchestration nodes that each serve their own UI over TLS. The Cichlid pattern. Default is still Vite + nginx.
|
||||
|
||||
### Desktop
|
||||
Tauri. Consumes the same `<app>-api` as the web client. Shares types via the `<app>-entities` crate (exposed to the Tauri frontend via generated TypeScript bindings — `ts-rs` or `specta`).
|
||||
|
||||
### Mobile
|
||||
**Preferences TBD.** When a project targets mobile, the goal is a framework that consumes the existing backend API (keeping business logic server-side) and produces responsive native-quality UIs for both Android and iOS from a shared codebase. Revisit this section once there's real-world experience to draw on.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data
|
||||
|
||||
### Central database: Postgres
|
||||
Default for any app with a central data store.
|
||||
|
||||
- Connection is **mTLS with passwordless auth**. Host-level client certificates issued by the internal step-ca, with cert CN → pg role mapping via `pg_ident.conf`.
|
||||
- No passwords in config files, ever. Connection strings reference cert paths.
|
||||
- Migrations via `sqlx-cli` or `refinery`; migration files live in `crates/<app>-data/migrations/`.
|
||||
- Schema changes are forward-only in production. Destructive migrations require a dedicated maintenance window and an explicit plan.
|
||||
- Use `sqlx` with compile-time query checking (`sqlx prepare`) and commit the generated `.sqlx/` offline query cache so CI builds don't need a live database.
|
||||
|
||||
### Distributed database: Turso
|
||||
When the app's data model is distributed (edge replicas, per-site local copies with sync), use Turso. Auth via Turso-issued tokens stored in the per-host secret store, not in `manifest.yml`.
|
||||
|
||||
### Caching / ephemeral state
|
||||
Prefer in-process (moka, quick-cache) over introducing Redis. Only add Redis when multiple processes genuinely need to share ephemeral state and Postgres LISTEN/NOTIFY won't do.
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment Assets (`asset/`)
|
||||
|
||||
`asset/` is the single source of truth for what gets deployed where. **No secrets in this directory, ever** — it's in source control.
|
||||
|
||||
```
|
||||
asset/
|
||||
├── manifest.yml # environments → components → hosts
|
||||
├── systemd/
|
||||
│ ├── <app>-api.service
|
||||
│ ├── <app>-api.socket # if socket-activated
|
||||
│ ├── <app>-worker.service
|
||||
│ ├── <app>-indexer.timer
|
||||
│ └── <app>-indexer.service
|
||||
├── nginx/
|
||||
│ └── <app>.<site>.conf # per server_name configs
|
||||
├── config/
|
||||
│ ├── config.toml.tmpl # templated; {{SECRET_NAME}} placeholders
|
||||
│ └── ...
|
||||
└── sql/
|
||||
└── bootstrap.sql # idempotent role/db creation
|
||||
```
|
||||
|
||||
### `manifest.yml` structure
|
||||
|
||||
```yaml
|
||||
app: <app>
|
||||
environments:
|
||||
prod:
|
||||
components:
|
||||
api:
|
||||
hosts: [oolon.hanzalova.internal]
|
||||
config:
|
||||
bind: 0.0.0.0:8080
|
||||
log_level: info
|
||||
worker:
|
||||
hosts: [gramathea.kosherinata.internal, oolon.hanzalova.internal]
|
||||
config:
|
||||
concurrency: 4
|
||||
web:
|
||||
hosts: [cdn.hanzalova.internal]
|
||||
root: /var/www/<app>
|
||||
dev:
|
||||
components:
|
||||
api:
|
||||
hosts: [quadbrat.hanzalova.internal]
|
||||
config:
|
||||
bind: 0.0.0.0:8080
|
||||
log_level: debug
|
||||
# ...
|
||||
```
|
||||
|
||||
Top-level keys: `app`, `environments`. Each environment defines `components`, each component defines `hosts` (one or many) and `config` (non-secret values only). Secret references are placeholders resolved by `deploy.sh` at deploy time.
|
||||
|
||||
### Templated config
|
||||
Config file templates use a simple `{{VAR_NAME}}` syntax. `deploy.sh` substitutes values from the host's `pass` store (or equivalent) into the template before shipping to the target. The unrendered template is committed; the rendered file never is.
|
||||
|
||||
---
|
||||
|
||||
## 7. Deployment Script (`script/deploy.sh`)
|
||||
|
||||
A bash script with a stable CLI:
|
||||
|
||||
```
|
||||
./script/deploy.sh <environment> [component...]
|
||||
|
||||
./script/deploy.sh prod api worker
|
||||
./script/deploy.sh dev all
|
||||
./script/deploy.sh prod default
|
||||
```
|
||||
|
||||
### Contract
|
||||
- First positional arg is the environment name, matched against `manifest.yml` `environments.*`.
|
||||
- Subsequent args name components, or `all` (every component in the environment), or `default` (a project-defined sensible subset — often everything except disruptive migrations).
|
||||
- Parses `manifest.yml` with `yq`.
|
||||
- For each `(component, host)` pair:
|
||||
1. Build the artifact locally (cargo build release, vite build, etc.) if not already built for the current commit.
|
||||
2. Resolve secrets from `pass` (or the project's configured secret backend) and render config templates.
|
||||
3. `rsync` binary + rendered config + systemd units to the target host over ssh (quantum-safe key exchange).
|
||||
4. `ssh` to the target to `systemctl daemon-reload` and restart the unit(s).
|
||||
5. Verify health (HTTP probe for api, `systemctl is-active` for all).
|
||||
- Exit non-zero on any failure. Report per-host status at the end.
|
||||
|
||||
### Requirements
|
||||
- Idempotent. Running twice with no changes is a no-op beyond file copies.
|
||||
- Quiet on success, loud on failure.
|
||||
- Supports `--dry-run` to print what would happen.
|
||||
- Never writes secrets to disk on the build host outside of the rendered template being rsynced.
|
||||
|
||||
---
|
||||
|
||||
## 8. Infrastructure Context
|
||||
|
||||
This is the environment these apps deploy into. Implementation should assume it.
|
||||
|
||||
### Network
|
||||
- Multi-site WireGuard mesh. Sites are numbered; host IPs follow `10.<site>.0.0/16` (currently `10.3.0.0/16` and `10.6.0.0/16`, but the second octet encodes the site and is the stable part).
|
||||
- Per-site OPNsense router handles WAN/LAN and the WireGuard endpoints.
|
||||
- Internal DNS split-horizon via `.internal` domains (`hanzalova.internal`, `kosherinata.internal`, etc.).
|
||||
|
||||
### TLS / PKI
|
||||
- Internal PKI via Smallstep `step-ca` at `ca.internal`.
|
||||
- Host certs renewed via systemd timers.
|
||||
- mTLS everywhere internal services talk to each other.
|
||||
- **Quantum-safe** SSH (sntrup761x25519 KEX) and TLS (X25519MLKEM768 where peers support it) are the default. External peers that don't support PQ fall back to classical curves — document the fallback explicitly in nginx config.
|
||||
|
||||
### Ingress
|
||||
- Per-site nginx reverse proxy terminates all WAN inbound 443.
|
||||
- Public DNS via Cloudflare, **unproxied by default** (CF's mTLS origin-pull has been unreliable). Revisit if/when that changes.
|
||||
- nginx serves static frontends directly from `/var/www/<app>` and reverse-proxies API traffic to the internal host:port from `manifest.yml`.
|
||||
|
||||
### Hosts
|
||||
- Workstations and servers run the latest Fedora.
|
||||
- Services run as dedicated non-root users created by RPM scaffolding or `sysusers.d` drop-ins shipped in `asset/systemd/`.
|
||||
- SELinux enforcing. Any non-standard file paths or ports require a policy module shipped with the app.
|
||||
- Podman quadlets for containerised workloads; bare-metal systemd units for native Rust binaries (preferred where feasible).
|
||||
|
||||
---
|
||||
|
||||
## 9. Code Quality and Tooling
|
||||
|
||||
### Formatting and linting
|
||||
- `cargo fmt` on commit (pre-commit hook or CI gate).
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings` must pass.
|
||||
- Frontend: `eslint` + `prettier`, configured to match the team style. Type errors fail the build.
|
||||
|
||||
### Testing
|
||||
- Unit tests live alongside the code they test (`#[cfg(test)] mod tests`).
|
||||
- Integration tests under `crates/<crate>/tests/`.
|
||||
- End-to-end tests that require a database use a dedicated test DB per run, created and torn down by the test harness.
|
||||
- Target: core business logic has meaningful test coverage. Binaries have smoke tests.
|
||||
|
||||
### Observability
|
||||
- `tracing` with `tracing-subscriber`. JSON output in production, pretty output when stdout is a TTY.
|
||||
- Structured log fields for request IDs, user IDs (where applicable), and operation names.
|
||||
- No `println!` or `eprintln!` in committed code outside of CLI binaries' user-facing output.
|
||||
|
||||
### Error handling
|
||||
- Library crates use `thiserror` for typed errors.
|
||||
- Binaries use `anyhow` at the outermost layer, with `.context(...)` at call boundaries.
|
||||
- Never `unwrap()` in production code paths. `expect("...")` with a clear message is acceptable for invariants that are genuinely impossible to violate.
|
||||
|
||||
### Documentation
|
||||
- Every public item in library crates has a doc comment.
|
||||
- Each crate has a `README.md` or top-level module doc explaining its role in the workspace.
|
||||
- The repo `README.md` covers: what the project does, how to build, how to run locally, how to deploy. Point readers to this document for architectural conventions.
|
||||
|
||||
---
|
||||
|
||||
## 10. Conventions Summary for Scaffolding and Implementation
|
||||
|
||||
When scaffolding or extending a project:
|
||||
|
||||
1. Default to the workspace layout in §1. Ask before deviating.
|
||||
2. Put new types in `entities`, new logic in `core`, new I/O in `data`. Binaries stay thin.
|
||||
3. Add dependencies to the workspace root first, then reference with `dep.workspace = true`.
|
||||
4. Version strings live in exactly one place — the workspace root.
|
||||
5. Any new deployable component gets an entry in `asset/manifest.yml` and a systemd unit in `asset/systemd/` in the same change.
|
||||
6. Config templates go in `asset/config/` with `{{PLACEHOLDER}}` secrets. Never commit a rendered config.
|
||||
7. Postgres connections are mTLS, passwordless. If writing connection code that accepts a password, stop and ask.
|
||||
8. Frontend is Vite + React + SWC + TS, served as static assets from nginx. Rust web frameworks require a stated reason.
|
||||
9. Prefer fewer dependencies. Prefer bare-metal systemd over containers unless there's a reason.
|
||||
10. When unsure, ask — these preferences are defaults, not mandates, but deviations should be deliberate.
|
||||
Reference in New Issue
Block a user