@@ -5,8 +5,9 @@ Rust (axum) API with PostgreSQL. Shipped so far: the production-grade skeleton,
55GitHub-OAuth authentication subsystem, the ** Repository + Workflow Management modules**
66(GitHub App integration, webhook-driven sync, workflow YAML parsing/validation, Monaco
77workspace with dependency graph), and the ** Pipeline Execution subsystem** (event-driven
8- scheduler, HMAC-signed runner WebSocket protocol, live log streaming, Cloudflare R2
9- artifacts, a reference Docker runner in ` runner/ ` , and the full Pipelines UI), and the
8+ scheduler, HMAC-signed runner WebSocket protocol, live log streaming, S3-compatible object
9+ storage for artifacts — MinIO as the default/primary store with Cloudflare R2 as the
10+ fallback — a reference Docker runner in ` runner/ ` , and the full Pipelines UI), and the
1011** Secrets Management module** (envelope-encrypted write-only workspace/repository/
1112environment secrets, dispatch-time injection with unconditional log masking, dedicated
1213` secrets.read ` /` secrets.manage ` RBAC, full catalog + detail UI), and the ** Environments
@@ -103,12 +104,23 @@ on (job, seq)) → broadcast to browser subscribers on `/ws/workspaces/{ws}/pipe
103104(strict Origin check + session cookie + ` content.read ` BEFORE upgrade; snapshot then live
104105events with ` createdAt ` ; server pings every 30 s and answers client ` {"type":"ping"} ` with
105106a pong; ` log_gap ` on lag → client backfills over REST, paging until exhausted). Artifacts
106- AND completed-job log archives live in ** Cloudflare R2** (S3 API; artifact presigned
107- PUT/GET minted server-side, HeadObject verification before rows flip to ` uploaded ` ; logs
108- gzip'd server-side to ` logs/{ws}/{pipeline}/{job}-{attempt}.log.gz ` on job completion —
109- feature-gated on R2 env, clean denial/Postgres-only without it; runner convention: files in
107+ AND completed-job log archives live in ** S3-compatible object storage**
108+ (` services/object_store.rs ` : one generic ` S3Store ` client with two flavors — ** MinIO is
109+ the default/primary backend whenever configured** (` services/minio.rs ` ; explicit endpoint,
110+ path-style addressing, startup bucket auto-create) and ** Cloudflare R2 the fallback**
111+ (` services/r2.rs ` ; derived account endpoint, region ` auto ` ) — R2 alone keeps its historical
112+ primary role. The ` Storage ` router in AppState handles it: server-side writes (log
113+ archives, logos) reactively fall back to the secondary store on failure; presigned upload
114+ grants route via a 60 s-cached HeadBucket health probe of the primary; and every stored
115+ object carries a ` storage_backend ` marker ('minio'|'r2', migration ` 20260716100001 ` ) so
116+ presigns/HeadObject/deletes always target the store that holds it — presigned URLs are
117+ host-specific, and NULL/legacy markers read as 'r2'. Artifact presigned PUT/GET minted
118+ server-side with the declared Content-Length bound into the signature, HeadObject
119+ verification before rows flip to ` uploaded ` ; logs gzip'd server-side to
120+ ` logs/{ws}/{pipeline}/{job}-{attempt}.log.gz ` on job completion — feature-gated on
121+ MinIO/R2 env, clean denial/Postgres-only without either; runner convention: files in
110122` .overup/artifacts/ ` ). ` services/janitor.rs ` (hourly) expires uploaded artifacts after
111- ` ARTIFACT_RETENTION_DAYS ` (deleting the R2 object), removes stale pending artifact rows,
123+ ` ARTIFACT_RETENTION_DAYS ` (deleting the stored object), removes stale pending artifact rows,
112124prunes archived log chunks past ` LOG_HOT_RETENTION_DAYS ` (the raw-log download then
113125307-redirects to a presigned R2 GET), and purges sessions/oauth states. The ledger list API
114126filters on repository, workflow, status, conclusion, trigger, branch, actor, runner, date
@@ -350,14 +362,16 @@ overup/
350362 │ # secrets (ciphertext-only + RBAC backfill),
351363 │ # environments (+secrets.environment_id scope +
352364 │ # RBAC backfill), secret value_set_at rotation clock,
353- │ # notifications (+preferences/projector cursor)
365+ │ # notifications (+preferences/projector cursor),
366+ │ # storage_backend markers (artifacts/pipeline_jobs/
367+ │ # workspaces — MinIO/R2 object routing)
354368 └── src/
355369 ├── main.rs # bootstrap: env, tracing, pool, migrate, orphan recovery,
356370 │ # scheduler spawn, janitor, serve
357371 ├── config.rs # all env-driven configuration (GitHub App, signing key,
358372 │ # execution budgets, optional R2 group)
359373 ├── state.rs # AppState: pool, config, oauth, http, github_app,
360- │ # log_hub, runner_hub, scheduler, r2
374+ │ # log_hub, runner_hub, scheduler, storage
361375 ├── error.rs # AppError → sanitized JSON responses
362376 ├── db/ # parameterized sqlx queries only, one module per table
363377 ├── models/ # FromRow rows + camelCase response DTOs, per resource
@@ -372,7 +386,9 @@ overup/
372386 # auth_flow, workspace, authz (RBAC), repo_sync,
373387 # workflow_parse, pipeline_plan, pipeline_run (state
374388 # machine), scheduler, log_hub (mask+cap+broadcast),
375- # runner_hub, r2 (presign + HeadObject),
389+ # runner_hub, object_store (generic S3Store + Storage
390+ # router: MinIO primary / R2 fallback, presign +
391+ # HeadObject) + minio/r2 (per-backend constructors),
376392 # secrets_crypto (AES-256-GCM envelope encryption),
377393 # notification (mapping) + notification_hub (per-user
378394 # fan-out) + notification_projector (audit tail)
@@ -459,8 +475,11 @@ Secrets subsystem; DEKs and decrypted values ride in `zeroize::Zeroizing` buffer
459475` serde_yaml_ng ` (maintained serde_yaml fork; workflow
460476parsing under strict budgets), ` axum ` with the ** ` ws ` feature** (runner + browser WebSocket
461477upgrades), ` dashmap ` (RunnerHub connection registry + LogHub broadcast/mask maps),
462- ` futures-util ` (WS stream splitting), ` aws-sdk-s3 ` (Cloudflare R2 via its S3 API — custom
463- endpoint, region ` auto ` , presigned URLs; isolated in ` services/r2.rs ` ), and the local
478+ ` futures-util ` (WS stream splitting), ` aws-sdk-s3 ` (one generic client for both object
479+ stores — MinIO via explicit endpoint + path-style addressing, Cloudflare R2 via its
480+ account endpoint + region ` auto ` ; presigned URLs, HeadObject/HeadBucket; isolated in
481+ ` services/object_store.rs ` with per-backend constructors in ` services/minio.rs ` /
482+ ` services/r2.rs ` ), and the local
464483` protocol ` crate (shared WS message types + HMAC helpers). The ` runner/ ` crate adds
465484` bollard ` 0.21 (Docker Engine API: image pull, container lifecycle, exec streams),
466485` tokio-tungstenite ` (rustls), ` tar ` + ` flate2 ` + ` bytes ` (repackaging the source tarball
@@ -475,7 +494,10 @@ into a traversal-safe tar streamed into the container via the Docker archive API
475494- Exact redirect-URI allow-list (one registered callback URL)
476495- Code exchange server-to-server over TLS (rustls), HTTP redirects disabled
477496- Session tokens: 32 bytes OS RNG; ** only SHA-256 hashes** in the database
478- - Session rotation on every login; absolute expiry; hourly janitor purges expired rows
497+ - Session rotation on every login; absolute expiry PLUS an idle timeout
498+ (` SESSION_IDLE_TIMEOUT_HOURS ` , default 72, 0 disables — rides the ` last_seen_at `
499+ column touched on every request, so a leaked token dies after inactivity); hourly
500+ janitor purges expired AND idle-expired rows with the same predicate
479501- Cookie: ` HttpOnly ` , ` Secure ` (prod), ` SameSite=Lax ` , ` Path=/ ` ; with ` COOKIE_SECURE=true `
480502 the name is auto-prefixed ` __Host- ` (binds the cookie to the exact host — no subdomain
481503 planting/fixation)
@@ -509,7 +531,20 @@ into a traversal-safe tar streamed into the container via the Docker archive API
509531 responses, never in Postgres; the private key PEM loads once at startup
510532- Webhooks: constant-time HMAC-SHA256 over the raw body (` X-Hub-Signature-256 ` ) before any
511533 parsing; ` X-GitHub-Delivery ` primary key makes redeliveries no-ops; payloads are parsed
512- into minimal typed envelopes and never logged
534+ into minimal typed envelopes and never logged; ` GITHUB_WEBHOOK_SECRET ` must be ≥ 16
535+ bytes at boot (signing-key parity — a guessable secret would let anyone forge deliveries)
536+ - Request tracing records method + PATH only (custom ` MakeSpan ` in routes) — the query
537+ string carries secrets on some routes (` ?code= ` /` ?state= ` on the OAuth callback,
538+ ` ?ticket= ` on WS upgrades) and must never land in spans, even at debug level
539+ - ** Object storage routing is marker-driven** : every stored object (artifact, log archive,
540+ logo) records which backend holds it (` storage_backend ` columns, 'minio'|'r2',
541+ NULL/legacy → 'r2'); presigns/HeadObject/deletes always target that store — presigned
542+ URLs are host-specific, so a marker mix-up would 404, never leak. Server-side writes
543+ fall back MinIO→R2 reactively; presigned upload grants route on a cached health probe.
544+ Presigned PUTs bind the runner-declared Content-Length (and content type) into the
545+ signed headers, so the store rejects any different body size at the edge; HeadObject
546+ re-verifies afterwards. MINIO_ENDPOINT is validated at boot and plain http on a
547+ non-loopback host draws a startup warning (cleartext credentials)
513548- Setup redirect: ` installation_id ` is length-capped, numeric-validated, then verified
514549 against the GitHub API with an app JWT + account/installer match before linking
515550- Workflow YAML parsing is deterministic and side-effect free: 512 KB cap, 20k node budget,
@@ -585,8 +620,9 @@ into a traversal-safe tar streamed into the container via the Docker archive API
585620- Storage hygiene: artifacts carry an immutable ` expires_at ` computed at upload from
586621 per-kind workspace retention policies (` artifact_retention_policies ` , 1–400 days,
587622 kind row → ` default ` row → ` ARTIFACT_RETENTION_DAYS ` env); the hourly janitor deletes
588- expired/abandoned R2 objects and rows, and prunes archived log chunks only when the R2
589- archive exists (` LOG_HOT_RETENTION_DAYS ` ). Artifact ` kind ` is classified SERVER-side
623+ expired/abandoned stored objects (routed per-object to MinIO or R2 by marker) and rows,
624+ and prunes archived log chunks only when the object-storage archive exists
625+ (` LOG_HOT_RETENTION_DAYS ` ). Artifact ` kind ` is classified SERVER-side
590626 from the validated name (` services/artifact_kind.rs ` ); runner-reported archive manifests
591627 (entries/uncompressed size/file count) are capped (1000 entries, 96 KB JSON, 1 TiB/1M
592628 ceilings) and dropped whole on any violation — the upload itself still succeeds
@@ -650,9 +686,14 @@ into a traversal-safe tar streamed into the container via the Docker archive API
650686# (openssl rand -hex 32) enables the Secrets module — without it secret
651687# creation is denied and pipelines for repos WITH stored secrets fail
652688# closed (secrets_unavailable); losing it makes stored values permanently
653- # undecryptable (re-enter values to recover). R2_* vars are optional —
654- # without them pipelines run but artifact uploads are denied and logs
655- # stay in Postgres (no archival/pruning). Retention knobs:
689+ # undecryptable (re-enter values to recover). Object storage is optional
690+ # and S3-compatible with two backends: MINIO_* vars (endpoint/key/secret/
691+ # bucket — the local docker-compose MinIO is http://localhost:9000 with
692+ # overup / overup-minio; bucket auto-created at startup) make MinIO the
693+ # DEFAULT/primary store, and R2_* vars configure Cloudflare R2 as the
694+ # fallback (or the primary when MinIO is absent). Without either,
695+ # pipelines run but artifact uploads are denied and logs stay in
696+ # Postgres (no archival/pruning). Retention knobs:
656697# ARTIFACT_RETENTION_DAYS / ARTIFACT_PENDING_TTL_HOURS /
657698# LOG_HOT_RETENTION_DAYS — per-kind artifact retention (1–400 days) is
658699# also configurable per workspace in the Artifacts UI and takes
0 commit comments