Skip to content

Commit 2bd5750

Browse files
authored
Merge pull request #36 from BotCoder254/feat/log-chunk-sections
Add MinIO primary storage with backend markers
2 parents eeac578 + 4848348 commit 2bd5750

32 files changed

Lines changed: 1075 additions & 306 deletions

CLAUDE.md

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ Rust (axum) API with PostgreSQL. Shipped so far: the production-grade skeleton,
55
GitHub-OAuth authentication subsystem, the **Repository + Workflow Management modules**
66
(GitHub App integration, webhook-driven sync, workflow YAML parsing/validation, Monaco
77
workspace 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/
1112
environment 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
104105
events with `createdAt`; server pings every 30 s and answers client `{"type":"ping"}` with
105106
a 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,
112124
prunes archived log chunks past `LOG_HOT_RETENTION_DAYS` (the raw-log download then
113125
307-redirects to a presigned R2 GET), and purges sessions/oauth states. The ledger list API
114126
filters 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
460476
parsing under strict budgets), `axum` with the **`ws` feature** (runner + browser WebSocket
461477
upgrades), `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

backend/.env.example

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,33 @@ MAX_LOG_BYTES_PER_JOB=10485760
151151
MAX_ARTIFACT_BYTES=104857600
152152
MAX_ARTIFACTS_PER_JOB=10
153153

154-
# Cloudflare R2 storage (S3-compatible API) for artifacts AND gzip'd log
155-
# archives. All four together or none: without R2, pipelines run fine but
156-
# artifact uploads are denied and logs simply stay in Postgres.
154+
# Object storage for artifacts AND gzip'd log archives (and workspace
155+
# logos). Two S3-compatible backends are supported; configure either, both,
156+
# or neither:
157+
# - MinIO (below) is the DEFAULT / primary store whenever it is configured.
158+
# - Cloudflare R2 (further below) is the fallback when MinIO is also
159+
# configured, and the primary when it is the only one.
160+
# With both set, server-side writes fall back to R2 automatically when the
161+
# MinIO write fails, and every object remembers which store holds it. With
162+
# neither, pipelines run fine but artifact uploads are denied and logs
163+
# simply stay in Postgres.
164+
165+
# MinIO (or any S3-compatible endpoint). All four together or none. The
166+
# bucket is created at startup when missing. The local docker-compose MinIO
167+
# uses: endpoint http://localhost:9000, credentials overup / overup-minio.
168+
# Plain http is fine on localhost; a non-loopback http endpoint draws a
169+
# startup warning (credentials would cross the network in cleartext).
170+
#MINIO_ENDPOINT=http://localhost:9000
171+
#MINIO_ACCESS_KEY_ID=
172+
#MINIO_SECRET_ACCESS_KEY=
173+
#MINIO_BUCKET=overup
174+
# Optional: region (default us-east-1) and path-style addressing (default
175+
# true — the standard MinIO deployment shape).
176+
#MINIO_REGION=us-east-1
177+
#MINIO_FORCE_PATH_STYLE=true
178+
179+
# Cloudflare R2. All four together or none: acts as the fallback store when
180+
# MinIO is configured, or the primary when it is the only backend.
157181
# Create an API token at Cloudflare -> R2 -> Manage R2 API Tokens.
158182
#R2_ACCOUNT_ID=
159183
#R2_ACCESS_KEY_ID=
@@ -169,9 +193,13 @@ ARTIFACT_RETENTION_DAYS=30
169193
# Hours a never-confirmed (pending) artifact row may linger before cleanup.
170194
ARTIFACT_PENDING_TTL_HOURS=24
171195
# Days a finished job's log chunks stay hot in Postgres after archival to
172-
# R2 (only pruned when R2 is configured).
196+
# object storage (only pruned when MinIO or R2 is configured).
173197
LOG_HOT_RETENTION_DAYS=7
174198

199+
# Session hardening. Sessions expire after SESSION_TTL_HOURS absolutely, and
200+
# ALSO after this many hours of inactivity (0 disables the idle timeout).
201+
SESSION_IDLE_TIMEOUT_HOURS=72
202+
175203
# Notification Center retention (independent from audit retention — the
176204
# audit ledger is permanent; notifications are operational awareness).
177205
# Days an ARCHIVED notification is kept before hard deletion.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Per-object storage-backend markers. Presigned URLs, HeadObject and
2+
-- DeleteObject are host-specific, so once MinIO (primary) and R2 (fallback)
3+
-- coexist every stored object must remember which store holds it. Existing
4+
-- rows were all written when R2 was the only store, hence the defaults; the
5+
-- nullable columns read as 'r2' when NULL (legacy rows).
6+
7+
ALTER TABLE artifacts
8+
ADD COLUMN storage_backend TEXT NOT NULL DEFAULT 'r2'
9+
CONSTRAINT artifacts_storage_backend_check
10+
CHECK (storage_backend IN ('minio', 'r2'));
11+
12+
-- Set alongside logs_archived_at going forward; NULL = legacy archive in R2.
13+
ALTER TABLE pipeline_jobs
14+
ADD COLUMN logs_archive_backend TEXT
15+
CONSTRAINT pipeline_jobs_logs_archive_backend_check
16+
CHECK (logs_archive_backend IN ('minio', 'r2'));
17+
18+
-- Set alongside logo_key going forward; NULL = legacy logo in R2.
19+
ALTER TABLE workspaces
20+
ADD COLUMN logo_storage_backend TEXT
21+
CONSTRAINT workspaces_logo_storage_backend_check
22+
CHECK (logo_storage_backend IN ('minio', 'r2'));

0 commit comments

Comments
 (0)