23 Commits

Author SHA1 Message Date
erangel1 e7c64e583b updated .env file to reflect local development. removed AI features from agents and changelog files. 2026-05-17 19:40:24 +02:00
erangel1 f658d754a8 Merge branch 'main' of https://gitea.dokploy.second-breakfast.dev/HomeLab/ForgeBucket 2026-05-17 19:18:40 +02:00
erangel1 a7b1fd2ae3 updated dev docker compose file to add dbgate, which is a light weight db manager. mainly just for dev operations 2026-05-17 19:18:04 +02:00
erangel1 ee1b56e833 Update .env 2026-05-13 00:06:19 +00:00
erangel1 2d6aabab9f Update .env 2026-05-12 23:59:08 +00:00
erangel1 54d6e6be36 Update .env 2026-05-12 23:54:58 +00:00
erangel1 7196b9f264 Update docker-compose.prod.yml 2026-05-12 23:51:12 +00:00
erangel1 f675032786 Update docker-compose.prod.yml 2026-05-12 23:44:31 +00:00
erangel1 cff6701864 Update docker-compose.prod.yml 2026-05-12 23:43:23 +00:00
erangel1 469d900ac8 Update docker-compose.prod.yml 2026-05-12 23:42:25 +00:00
erangel1 366941feb1 Update docker-compose.prod.yml 2026-05-12 23:41:24 +00:00
erangel1 df6d53c12c Update docker-compose.prod.yml 2026-05-12 23:38:11 +00:00
erangel1 d384af0d9c Delete ai_agent_master_prompt_for_building_modern_git_platform.md 2026-05-12 23:30:34 +00:00
erangel1 dea58b85b8 fixed issues from opencode agent 2026-05-13 01:08:19 +02:00
erangel1 994570ca74 added ai prompt to gitignore file 2026-05-13 00:56:41 +02:00
erangel1 77268e2302 edited ci file 2026-05-13 00:55:28 +02:00
erangel1 f99f0e0fc5 random edits 2026-05-12 22:51:04 +02:00
erangel1 91462500f0 added artifacts 2026-05-12 22:34:26 +02:00
erangel1 822f723ff1 added signed artifacts and SBOM generation capabilities 2026-05-12 21:31:43 +02:00
erangel1 ab94775162 implemented federation 2026-05-12 20:55:13 +02:00
erangel1 e360f3697e implemented observability 2026-05-12 20:32:30 +02:00
erangel1 c7df53708c implemented gitops controller + drift detection 2026-05-12 19:51:59 +02:00
erangel1 35afa8d8f1 fixed PR issue 2026-05-11 23:56:45 +02:00
93 changed files with 7691 additions and 1271 deletions
Vendored
BIN
View File
Binary file not shown.
+15 -2
View File
@@ -21,7 +21,7 @@ NATS_URL=nats://localhost:4222
# ─── Federation (ActivityPub) ─────────────────────────────────────────────────
# Public URL of this instance (no trailing slash)
INSTANCE_URL=https://forgebucket.asgardlabs.net
INSTANCE_URL=https://forgebucket.dokploy.second-breakfast.dev
INSTANCE_NAME=ForgeBucket
# ─── OIDC / OAuth2 (optional) ────────────────────────────────────────────────
@@ -31,4 +31,17 @@ INSTANCE_NAME=ForgeBucket
# ─── Dev only ─────────────────────────────────────────────────────────────────
# Set to true to disable Secure cookies and enable verbose logging
DEBUG=true
DEBUG=false
# PEM-encoded ECDSA P-256 private key. If empty, an ephemeral key is generated
# at startup (signatures will not survive restart). Generate with:
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
# ARTIFACT_SIGNING_KEY="-----BEGIN EC PRIVATE KEY-----
# MHcCAQEEIKGMjCu0NdczHQ7BRDeo0hTOLauF9vOenWl0HlyN4bzToAoGCCqGSM49
# AwEHoUQDQgAE+VL1HhQ1us0QfNH+5Var8lo5Oww83B+QDQ2obzHL4JZl0UM3kVAB
# SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
# -----END EC PRIVATE KEY-----"
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
# Root directory for the OCI Distribution Spec blob and upload storage.
OCI_ROOT=/tmp/forgebucket/oci
+14
View File
@@ -26,11 +26,25 @@ INSTANCE_NAME=ForgeBucket
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# ─── GitOps ──────────────────────────────────────────────────────────────────
# Seconds between periodic drift checks (0 disables the ticker; push-triggered checks always run).
GITOPS_RECONCILE_INTERVAL=300
# ─── Event Bus (NATS) ────────────────────────────────────────────────────────
# Leave empty to disable event publishing (no-op mode).
# Start NATS with: make docker-up
NATS_URL=nats://localhost:4222
# ─── Artifact Signing (Phase 4) ───────────────────────────────────────────────
# PEM-encoded ECDSA P-256 private key. If empty, an ephemeral key is generated
# at startup (signatures will not survive restart). Generate with:
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
# ARTIFACT_SIGNING_KEY=
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
# Root directory for the OCI Distribution Spec blob and upload storage.
OCI_ROOT=/var/lib/forgebucket/oci
# ─── Dev only ─────────────────────────────────────────────────────────────────
# Set to true to disable Secure cookies and enable verbose logging
DEBUG=false
+3
View File
@@ -13,3 +13,6 @@ uploads
# Database
*.db
ai_agent_master_prompt_for_building_modern_git_platform.md
html docs/
+118 -67
View File
@@ -19,73 +19,87 @@ The full product vision lives in [`ai_agent_master_prompt_for_building_modern_gi
## Architecture Map
```
cmd/forgebucket/ — binary entry point (main.go)
cmd/forgebucket/ — binary entry point (main.go)
internal/
api/
router.go — Chi router, all route definitions (~26 routes)
middleware/ — auth, CSRF, RBAC, logging
handlers/ — one file per domain (repo, pr, issue, auth, user, ssh...)
router.go — Chi router, all route definitions (60+ routes)
middleware/ — auth.go, csrf.go, rbac.go, audit.go
handlers/ — one file per domain area (see Key Files below)
domain/
git/ — sanitized git binary wrapper (exec.Command only, no shell)
federation/ — ActivityPub / ForgeFed (DATA LAYER ONLY — no handlers yet)
ci/ CI orchestrator (EMPTY — Phase 2 stub)
models/XORM structs + 7 migration files
config/ ENV-driven config, fails fast on missing secrets
web/ — //go:embed target for the built React SPA
git/ — sanitized git binary wrapper (exec.Command only, no shell)
binary.go — Run, Log, Tree, Diff, BlobCat, RevParse, etc.
agit.goAGit ref parsing
ci/ CI/CD execution engine (fully built — Phase 2B)
orchestrator.goNATS-driven DAG orchestrator
runner_manager.go — job dispatch with Docker executor
executor.go — docker run, log streaming, workspace extraction
dag.go — topological sort, ReadyJobs
parser.go — .forgebucket/workflows/*.yml parser
types.go — WorkflowFile, WorkflowJob, WorkflowStep structs
sbom/ — SBOM generator (fully built — Phase 4)
generator.go — CycloneDX 1.4 generation, auto on pipeline success + on-demand
cyclonedx.go — CycloneDX document model and helpers
parsers.go — Manifest parsers: go.mod, package.json, requirements.txt, Cargo.toml, Gemfile.lock, pom.xml
scanning/ — Secret scanner (fully built — Phase 4)
scanner.go — Push-triggered regex scanning, listing, dismissal
secrets.go — 15 high/medium severity secret patterns (AWS, GitHub, SSH, JWT, etc.)
vulnscan/ — Vulnerability scanner (fully built — Phase 4)
scanner.go — OSV API-backed dependency vulnerability scanning
osv.go — HTTP client for api.osv.dev/v1
signing/ — Artifact signing (fully built — Phase 4)
keystore.go — ECDSA P-256 signing and verification, self-verifying bundles
oci/ — OCI registry (fully built — Phase 4)
registry.go — Content-addressable blob store, upload sessions, OCI Distribution Spec v1.1
gitops/ — GitOps controller (fully built — Phase 3D)
controller.go — NATS subscriptions, startup, periodic ticker
drift.go — CheckDrift, handlePush, periodicCheck
reconciler.go — TriggerSync, handleDeploymentSucceeded/Failed
federation/ — ActivityPub / ForgeFed (fully built — Phase 3F)
actor.go — GetOrCreate, ActorJSON, APID, RSA-2048 key gen
signatures.go — HTTP signature sign/verify (draft-cavage-http-signatures)
inbox.go — Receive, handleFollow (auto-accept), handleAccept, handleUndo
outbox.go — Collection builder, StubCollection
remote.go — FetchActor (cached), DeliverActivity (signed POST)
observability/ — Prometheus metrics + health (fully built — Phase 3E)
metrics.go — metric definitions, HTTP middleware, NATS watcher
health.go — Check() returning HealthStatus (DB ping + NATS)
models/ — XORM structs + 20 migration files
config/ — ENV-driven config, fails fast on missing secrets
events/ — NATS EventBus interface + NATSBus + NoOpBus (Healthy() bool)
web/ — //go:embed target for the built React SPA
frontend/
src/
pages/ — 20 route-level page components
components/ — shared UI (AppShell, Sidebar, Header, DiffViewer, etc.)
pages/ — route-level page components
components/ — shared UI (AppShell, Sidebar, Header, DiffViewer, etc.)
ui/
tokens.ts — SINGLE SOURCE OF TRUTH for all design tokens
hooks/ — custom React hooks
api/ — typed API client (fetch wrappers)
tokens.ts — SINGLE SOURCE OF TRUTH for all design tokens
hooks/ — custom React hooks
api/ — typed API client (fetch wrappers)
```
**Middleware chain — this order is fixed, do not reorder:**
```
Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → Handler
Logger → RealIP → Recoverer → Metrics → CORS → CSRF → SessionAuth → AuditLog → Handler
```
---
## Current Phase Status
Understand the phases before adding code — don't build Phase 3 infrastructure when Phase 2 is incomplete.
| Phase | Scope | Status |
|-------|-------|--------|
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system, 20-page SPA | **Complete** |
| 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** |
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system | **Complete** |
| 2A | NATS event bus, WebSocket hub, audit log | **Complete** |
| 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** |
| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Complete** |
| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | **Complete** |
| 3A | Environment model + deployment tracking | **Complete** |
| 3B | Unified operational timeline | **Complete** |
| 3C | Workspaces + secret management hierarchy | **Active** |
| 3D | GitOps controller + drift detection | Planned |
| 3E | Observability (Prometheus, health sparklines) | Planned |
| 3F | Federation handlers (ActivityPub inbox/outbox) | Planned |
| 4 | AI diagnostics, signed artifacts, OCI registry, secret/dep scanning | Planned |
Do not implement Phase 3+ features without explicit discussion. The `domain/federation/` directory is an intentional stub — the data model exists but no HTTP handlers should be wired until Phase 3F.
### Phase 3A — What to Build
Backend and frontend are both net-new for Phase 3A. Nothing exists yet.
**Backend:**
1. `internal/models/environment.go``Environment` (id, repoId, name, url, protectionRules JSON) + `Deployment` (id, envId, repoId, sha, ref, status, triggeredBy, description, runId, startedAt, finishedAt)
2. `internal/models/migrations/010_environments.go``Run010()` syncing both structs; call from `001_init.go`
3. `internal/api/handlers/environment.go``ListEnvironments`, `CreateEnvironment`, `GetEnvironment`, `UpdateEnvironment`, `DeleteEnvironment`, `ListDeployments`, `CreateDeployment`, `UpdateDeploymentStatus`; publish `deployment.*` NATS events
4. `internal/api/router.go` — wire routes under `/{owner}/{repo}/environments` and `/{owner}/{repo}/environments/{envName}/deployments`
**Frontend:**
5. `frontend/src/types/api.ts` — add `Environment`, `Deployment`, `DeployStatus` types
6. `frontend/src/api/queries/environments.ts``useEnvironments`, `useEnvironment`, `useCreateEnvironment`, `useUpdateEnvironment`, `useDeleteEnvironment`, `useDeployments`, `useCreateDeployment`, `useUpdateDeploymentStatus`
7. `frontend/src/pages/EnvironmentsPage.tsx` — environment cards each showing latest deployment status, SHA, who deployed, time; "New environment" flow; deployment history per env
8. `frontend/src/components/layout/Sidebar.tsx` — add `Environments` nav item between Pipelines and Settings in `RepoSubNav`
9. `frontend/src/pages/RepoPage.tsx` — surface deployment status badges in the repo header (latest deploy per env at a glance)
10. `frontend/src/App.tsx` — add route `repos/:owner/:repo/environments`
| 3C | Workspaces + secret management (Global → Workspace → Repo → Env) | **Complete** |
| 3D | GitOps controller + drift detection + auto-sync | **Complete** |
| 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | **Complete** |
| 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures, Follow/Accept) | **Complete** |
| 4 | SBOM generation, secret scanning, vuln scanning, signed artifacts, OCI registry, security page | **Complete** |
| 5 | Deployment promotions, rollback visualization | Planned |
---
@@ -107,13 +121,19 @@ This rule is non-negotiable. It prevents command injection.
### Router / handlers
- Chi router. Route definitions in `internal/api/router.go`.
- One handler file per domain area. Keep handlers thin — business logic belongs in domain packages.
- All POST/PUT/DELETE routes require `X-CSRF-Token` header matching the session cookie. The middleware enforces this, but don't remove it from routes.
- All POST/PUT/DELETE routes require `X-CSRF-Token` header matching the session cookie. The CSRF middleware enforces this, but don't remove it from route definitions.
- There is a shared `resolveRepoID(db, w, r)` function in `internal/api/handlers/repo_lookup.go` — use it instead of duplicating repo resolution logic.
### Database
- XORM for all DB access. Structs in `internal/models/`.
- Migrations are numbered files in `internal/models/migrations/`. Always add a new migration file; never edit existing ones.
- Migrations are numbered files in `internal/models/migrations/`. Always add a new file; never edit existing ones. Current highest: **020**.
- No raw SQL strings built from user input.
### Events
- Publish to NATS via `bus.Publish(events.SubjectXxx, payload)` where the subject is a constant from `internal/events/subjects.go`.
- Payload types are in `internal/events/types.go` — use them for type-safe unmarshaling in subscribers.
- `NoOpBus` silently drops events when `NATS_URL` is unset — the app must work normally without NATS.
### Secrets and config
- All secrets come from environment variables via `internal/config/`.
- Never hardcode secrets, tokens, or credentials anywhere.
@@ -121,7 +141,7 @@ This rule is non-negotiable. It prevents command injection.
### Error handling
- Return errors up the call stack. Don't swallow them silently.
- HTTP handlers use consistent JSON error responses — follow the pattern in existing handlers.
- HTTP handlers use consistent JSON error responses — follow the pattern in `jsonError` / `jsonOK` in `internal/api/handlers/helpers.go`.
---
@@ -143,7 +163,7 @@ All spacing, color, and sizing values must come from `frontend/src/ui/tokens.ts`
- Touch targets: 44px minimum height/width on all interactive elements (buttons, links, icon buttons).
### Dark mode
- Use Tailwind v4 `@variant dark` — not hardcoded dark: classes unless inside a component that explicitly handles both.
- Use Tailwind v4 `@variant dark` — not hardcoded `dark:` classes unless inside a component that explicitly handles both.
- Colors must work in both light and dark modes. Test both.
### Component patterns
@@ -155,20 +175,21 @@ All spacing, color, and sizing values must come from `frontend/src/ui/tokens.ts`
### API calls
- Use the typed API client in `frontend/src/api/` — don't write raw `fetch` calls in components.
- Always include `X-CSRF-Token` header on mutating requests.
- Always include `X-CSRF-Token` header on mutating requests (the client does this automatically via `getCSRFToken()`).
---
## What NOT to Do
- **No shell string injection** — see Go conventions above
- **No shell string injection** — see Go conventions above; always discrete `exec.Command` args
- **No hardcoded secrets** — everything via env
- **No skipping CSRF** — all mutating routes require it
- **No arbitrary design values** — tokens.ts is the law
- **No Phase 3+ features without discussion** — don't wire up GitOps, federation handlers, or the command palette until Phase 2 is complete
- **No new color tokens** — if the design requires a new color, discuss it; don't invent one
- **No modal-heavy UX** — this platform uses progressive disclosure; avoid deep modal chains
- **No YAML-centric UI** — pipeline and environment config should feel operational, not config-file editing
- **No arbitrary design values** — `tokens.ts` is the law
- **No new color tokens without discussion** — the existing palette covers all cases
- **No modal-heavy UX** — progressive disclosure; avoid deep modal chains
- **No YAML-centric UI** — pipeline and GitOps config should feel operational, not config-file editing
- **No editing existing migration files** — always add a new numbered migration
- **No direct `fmt.Println` for logging** — use `log.Printf` so structured logs work correctly
---
@@ -189,16 +210,44 @@ make lint # go vet + ESLint
| File | Purpose |
|------|---------|
| `internal/api/router.go` | All route definitions — start here for backend |
| `internal/models/` | XORM models + migrations — all DB schemas |
| `internal/config/config.go` | Env-driven config, required vars |
| `internal/domain/git/` | Git binary wrapper — safe exec patterns |
| `internal/api/router.go` | All route definitions — start here for backend work |
| `internal/api/handlers/repo_lookup.go` | Shared `resolveRepoID` helper |
| `internal/models/` | All XORM models + 13 migration files |
| `internal/config/config.go` | All env vars, fail-fast validation |
| `internal/events/subjects.go` | All NATS event subject constants |
| `internal/events/types.go` | Typed event payload structs |
| `internal/domain/git/binary.go` | Git binary wrapper — safe exec patterns, `RevParse`, `BlobCat`, etc. |
| `internal/domain/ci/orchestrator.go` | CI DAG orchestrator |
| `internal/domain/ci/executor.go` | Docker job executor + log streaming |
| `internal/domain/gitops/controller.go` | GitOps reconciliation controller |
| `internal/domain/gitops/drift.go` | `CheckDrift`, drift detection logic |
| `internal/observability/metrics.go` | Prometheus metric defs, HTTP middleware, NATS watcher |
| `internal/observability/health.go` | `Check()` — DB ping + NATS liveness |
| `internal/api/handlers/observability.go` | `/health` + `/repos/.../health` handlers |
| `internal/api/handlers/environment.go` | Environment + deployment CRUD |
| `internal/api/handlers/gitops.go` | GitOps config + drift HTTP endpoints |
| `internal/api/handlers/federation.go` | ActivityPub WebFinger, actor, inbox, outbox, followers/following |
| `internal/domain/federation/actor.go` | Actor lifecycle — GetOrCreate, ActorJSON, key generation |
| `internal/domain/federation/signatures.go` | HTTP signature sign/verify |
| `internal/domain/federation/inbox.go` | Receive + Follow/Accept auto-accept flow |
| `internal/domain/federation/remote.go` | FetchActor (cached remote actors), DeliverActivity |
| `internal/api/handlers/secret.go` | Scoped secret management |
| `internal/api/handlers/workspace.go` | Workspace + member management |
| `internal/api/handlers/secret.go` | Scoped secret management |
| `internal/api/handlers/sbom.go` | SBOM generation + download endpoints |
| `internal/api/handlers/scanning.go` | Secret leak list + dismiss endpoints |
| `internal/api/handlers/vulnscan.go` | Vulnerability scan + dismiss endpoints |
| `internal/api/handlers/oci.go` | OCI Distribution Spec v1.1 registry handler |
| `internal/domain/sbom/generator.go` | SBOM generator (CycloneDX 1.4) |
| `internal/domain/scanning/scanner.go` | Push-triggered secret scanner |
| `internal/domain/vulnscan/scanner.go` | OSV API-backed vulnerability scanner |
| `internal/domain/signing/keystore.go` | ECDSA P-256 artifact signing |
| `internal/domain/oci/registry.go` | Content-addressable OCI blob store |
| `internal/api/middleware/audit.go` | Audit log middleware |
| `frontend/src/ui/tokens.ts` | Design token source of truth |
| `frontend/src/components/AppShell.tsx` | Root layout wrapper |
| `frontend/src/components/Sidebar.tsx` | 3-state navigation sidebar |
| `frontend/src/pages/` | All 20 route-level pages |
| `frontend/src/api/` | Typed API client |
| `.env.example` | All required environment variables |
| `frontend/src/api/client.ts` | Typed API client with CSRF handling |
| `.env.example` | All environment variables with documentation |
| `CLAUDE.md` | Developer guide (rules overlap with this file — CLAUDE.md takes precedence on conflicts) |
---
@@ -207,7 +256,9 @@ make lint # go vet + ESLint
```bash
cp .env.example .env # fill SESSION_SECRET and CSRF_SECRET
make docker-up # PostgreSQL via Docker Compose
make migrate # run XORM migrations
make docker-up # PostgreSQL + NATS via Docker Compose
make migrate # run XORM migrations (currently 020)
make dev # Go :8080 + Vite :5173
```
CI execution requires Docker to be running locally. If unavailable, the runner logs a warning and CI jobs are queued but not executed.
+336 -143
View File
@@ -9,63 +9,280 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### In Progress — Phase 3C (Workspaces + Secret management hierarchy)
- `Workspace` model — named collaborative namespace (handle, displayName, description, avatarUrl)
- `WorkspaceMember` model — user membership with owner/admin/member roles
- Repos can be owned by a workspace; URL format stays `/{owner}/{repo}` where owner is a workspace handle or username
- `Secret` model — AES-256-GCM encrypted, scoped to global / workspace / repo / env
- Secret hierarchy resolution in CI executor: Env → Repo → Workspace → Global
- Full CRUD APIs for workspaces, workspace members, secrets at all scope levels
- WorkspacesPage, WorkspacePage, WorkspaceSettingsPage (settings + members)
- Workspace switcher in sidebar header
- Create repo: workspace owner selector
- RepoSecretsPage — write-only secret management per repo and per environment
### Planned — Phase 5 (Deployment Promotions + Rollback Visualization)
- Deployment promotion workflows (manual + automated)
- Rollback visualization and timeline
---
## [1.0.0] — 2026-05-13
Phase 4 complete. SBOM generation, secret scanning, dependency vulnerability scanning, signed artifacts, and OCI registry are operational.
### Added — SBOM Generation (`internal/domain/sbom/`)
- **`Generator`** — subscribes to `pipeline.completed` events and auto-generates CycloneDX 1.4 SBOM documents for every successful pipeline run; also supports on-demand generation via `GenerateOnDemand`
- **6 manifest parsers**: `go.mod`, `package.json`, `requirements.txt`, `Cargo.toml`, `Gemfile.lock`, `pom.xml` — lightweight line-scanning, no external parser dependencies
- **API endpoints** — `GET /sbom`, `GET /sbom/document`, `GET /runs/{runID}/sbom`, `GET /runs/{runID}/sbom/document`, `POST /sbom/generate?ref=&runID=`
- **Database** — migration `016_sbom` adds `SBOMReport` model with CycloneDX document body
- Automatic generation on pipeline completion now also fires directly from the orchestrator (not solely via NATS), ensuring SBOMs are generated even when NATS is unavailable
### Added — Secret Scanning (`internal/domain/scanning/`)
- **`Scanner`** — subscribes to `push.received` events, scans git diffs against 15 regex patterns for high/medium severity secrets
- **Secret patterns**: AWS keys, GitHub/GitLab tokens, generic API keys, Bearer tokens, Slack tokens, Google API keys, Google service accounts, SSH private keys, JWTs, NPM tokens, PostgreSQL/Redis connection strings, generic passwords
- **API endpoints** — `GET /secrets/leaks`, `POST /secrets/leaks/{leakID}/dismiss` (repo-scoped), `GET /api/v1/secrets/leaks` (global admin)
- **Database** — migration `018_scanning` adds `SecretLeak` model
### Added — Vulnerability Scanning (`internal/domain/vulnscan/`)
- **`Scanner`** — triggers on-demand scans against the OSV API (`api.osv.dev/v1`); supports scanning by PURL or by fetching the latest SBOM and scanning all components
- **OSV client** — HTTP client with 30-second timeout, queries OSV database for CVEs by PURL or ecosystem+name, extracts CVSS scores and fixed version ranges
- **API endpoints** — `GET /vulnerabilities`, `POST /vulnerabilities/scan`, `POST /vulnerabilities/{findingID}/dismiss` (repo-scoped), `GET /api/v1/vulnerabilities` (global admin)
- **Database** — migration `019_vulnscan` adds `VulnerabilityFinding` model
- Findings deduplicated by `(vuln_id, purl, repo_id)`
### Added — Artifact Signing (`internal/domain/signing/`)
- **`KeyStore`** — ECDSA P-256 signing and verification; produces self-verifying `Bundle` carrying payload, signature (ASN.1 DER), and public key PEM
- `Sign(artifactID, name, rawContent)` — computes SHA-256 digest, signs, returns signed `Bundle` with key ID fingerprint
- `Verify(bundleJSON)` — extracts public key from bundle, verifies ECDSA signature, returns `VerifyResult` with key-matching check
- `Generate()` — creates ephemeral ECDSA P-256 key when `ARTIFACT_SIGNING_KEY` env var is unset (logs warning; signatures lost on restart unless persisted)
- **API endpoints** — `GET /artifacts/{artifactID}/signature`, `GET /artifacts/{artifactID}/verify`
- **Database** — migration `015_signing` adds `ArtifactSignature` model
### Added — OCI Registry (`internal/domain/oci/`)
- **`Registry`** — content-addressable on-disk blob store implementing OCI Distribution Spec v1.1
- Storage layout: `{root}/blobs/sha256/<hex>` for blobs, `{root}/uploads/<uuid>` for in-progress uploads
- Full upload session lifecycle: start (POST), append chunk (PATCH), finalize with digest verification (PUT), cancel (DELETE), offset query (GET)
- 13 OCI distribution error codes defined (`ErrBlobUnknown`, `ErrDigestInvalid`, `ErrManifestInvalid`, etc.)
- **API handlers** (`internal/api/handlers/oci.go`, 525 lines) — full `/v2/{name}/{kind}/{ref}` routing: manifest push/get/delete, blob HEAD/get/delete, tag listing, chunked upload
- **Database** — migration `017_oci` adds `OCIRepository`, `OCIManifest`, `OCITag`, `OCIBlob`, `OCIUpload` models
- Registry is consumed by standard OCI tools (Docker, Podman, Skopeo, containerd)
### Added — Unified Security Page (`/repos/:owner/:repo/security`)
- **`RepoSecurityPage`** — single-page view combining SBOM status, secret leak detection, and vulnerability findings
- SBOM section: displays existing SBOM metadata with download button, or "Generate SBOM" form with branch/SHA input
- Secret Leaks section: lists leaks with severity badge, pattern name, commit SHA, ref, match sample, dismiss button
- Vulnerabilities section: lists findings with CVSS severity (CRITICAL/HIGH/MEDIUM/LOW), vuln ID, score, summary, PURL, version, fix suggestion, dismiss button; "Scan now" trigger
- **Route** added in `App.tsx`, nav link added in `RepoPage.tsx` tab bar
### Added — Pipeline Run SBOM Integration
- `PipelineRunPage` shows per-run SBOM section: metadata (components, SHA, generation time) + download button
- "Generate SBOM" button for completed/failed runs that lack one
- `useRunSBOM` / `useGenerateSBOM` hooks in `frontend/src/api/queries/sbom.ts`
- 404 from `useRunSBOM` handled gracefully (returns `null` instead of throwing)
### Added — Database Models
- Migration `016_sbom``SBOMReport` (repoId, runId, sha, format, componentCount, bomDocument, generatedAt)
- Migration `017_oci``OCIRepository`, `OCIManifest`, `OCITag`, `OCIBlob`, `OCIUpload`
- Migration `018_scanning``SecretLeak` (repoId, commitSha, ref, patternName, description, severity, matchSample, dismissed, dismissedBy, dismissedAt, detectedAt)
- Migration `019_vulnscan``VulnerabilityFinding` (repoId, vulnId, purl, version, summary, details, cvssScore, fixedVersion, dismissed, dismissedBy, dismissedAt, detectedAt)
- Migration `020_forgefed` — repository + pull request column updates
### Fixed
- SBOM per-run download endpoint (`/runs/{runID}/sbom/document`) was registered at the wrong router nesting level, causing a route conflict with the `GetLatestDocument` handler. Moved into the correct `/runs/{runID}` route block.
- `username` context key extraction in scanning and vulnerability handlers changed from raw string `"user"` to typed `middleware.ContextKeyUsername`
- Nil-safe `Needs` marshalling in orchestrator job creation
- Nil-safe findings response in vulnerability scan API
- `GenerateOnDemand` SBOM cache key now includes `runID` to prevent per-run generation from being shadowed by prior on-demand generation
---
## [0.9.0] — 2026-05-12
Phase 3F complete. ForgeBucket is now a first-class ActivityPub node — interoperable with Mastodon, Forgejo, and any fediverse server.
### Added — ActivityPub Federation (`internal/domain/federation/`)
- **`GET /.well-known/webfinger`** — resolves `acct:user@domain` to the actor URL; returns `application/jrd+json`
- **`GET /users/{username}`** — returns a JSON-LD actor document (`Person` type) including public key object for HTTP signature verification
- **`POST /users/{username}/inbox`** — receives and dispatches inbound ActivityPub activities; HTTP signature verification enforced in production (skipped in `DEBUG=true` mode for local testing)
- **`GET /users/{username}/outbox`** — serves an `OrderedCollection` (summary on page 0, paginated `OrderedCollectionPage` on page ≥ 1, 20 activities per page)
- **`GET /users/{username}/followers`** — stub `OrderedCollection` (zero items; social graph in Phase 4)
- **`GET /users/{username}/following`** — stub `OrderedCollection`
### Added — HTTP Signatures (`internal/domain/federation/signatures.go`)
- `Sign(req, keyID, privateKeyPEM)` — signs outgoing HTTP requests with RSA-SHA256; covers `(request-target)`, `host`, and `date` headers
- `Verify(r, db, instanceURL)` — parses `Signature` header, resolves sender's public key (local `FederationActor` first, then network fetch via `FetchActor`), verifies RSA-SHA256 digest
### Added — Actor Lifecycle (`internal/domain/federation/actor.go`)
- `GetOrCreate` — lazily creates a `FederationActor` for a local user; generates a fresh RSA-2048 key pair and derives `InboxURL`, `OutboxURL`, `APID` from `INSTANCE_URL`; stable across requests
- `ActorJSON` — returns the JSON-LD document shape expected by all ActivityPub clients
- `APID(instanceURL, username)` — canonical `{instanceURL}/users/{username}` helper
### Added — Follow / Accept Flow (`internal/domain/federation/inbox.go`)
- Incoming `Follow` activities are auto-accepted: remote actor is fetched (or retrieved from cache), an `Accept` activity is signed and delivered to their inbox asynchronously
- Both the inbound `Follow` and outbound `Accept` are persisted to `FederationActivity` for audit
### Added — Remote Actor Cache (`internal/domain/federation/remote.go`)
- `FetchActor` — HTTP GET with `Accept: application/activity+json`, extracts inbox URL and public key PEM, stores in `RemoteActor` table to avoid repeated fetches
- `DeliverActivity` — marshals activity JSON, signs the request, POSTs to recipient inbox with 15-second timeout
### Added — Database Models (migration `014_federation`)
- `FederationActivity` — append-only log of all inbound and outbound activities: `ActorAPID`, `Type`, `ObjectJSON`, `Direction` (inbound/outbound), `RemoteActor`, `Published`
- `RemoteActor` — cache for remote actor documents: `APID` (unique), `InboxURL`, `PublicKey`, `FetchedAt`
---
## [0.8.0] — 2026-05-12
Phase 3E complete. Prometheus metrics, structured health checks, and per-repo operational health are operational.
### Added — Prometheus Metrics (`internal/observability/`)
- `GET /metrics` — Prometheus text format endpoint (standard root-level path for k8s/Prometheus scraping)
- `GET /health` — upgraded from static `{"status":"ok"}` to a structured liveness response:
`{"status":"healthy","checks":{"database":"ok","nats":"ok"},"version":"0.8.0"}`
Returns HTTP 503 when any dependency is degraded
- `internal/observability/metrics.go` — metric definitions:
- `forgebucket_http_requests_total{method,path,status}` — counter
- `forgebucket_http_request_duration_seconds{method,path}` — histogram (Prometheus default buckets)
- `forgebucket_pipeline_runs_total{status}` — counter (succeeded/failed/cancelled), pre-initialized to 0
- `forgebucket_deployments_total{status}` — counter (pending/success/failure/cancelled), pre-initialized to 0
- `forgebucket_active_pipeline_runs` — gauge (in-flight runs)
- `internal/observability/health.go``Check(db, bus)` pings PostgreSQL and calls `bus.Healthy()`
- HTTP instrumentation middleware inserted after `Recoverer`, before `CORS` — records every request
- Path normalization prevents label cardinality explosion: `/repos/alice/myrepo/runs/42`
`/api/v1/repos/:owner/:repo/runs/:id`
- NATS metric watcher subscribes to `pipeline.>` and `deployment.>` and increments counters
### Added — Per-Repo Operational Health (`GET /api/v1/repos/{owner}/{repo}/health`)
- Returns a JSON summary for the repo page operational header:
- `ciPassRate7d` — fraction of pipeline runs that succeeded in the last 7 days
- `totalRuns7d` — total run count in the last 7 days
- `latestRun` — most recent `PipelineRun` record
- `latestDeployments` — one entry per environment showing latest deploy (envName, status, sha, finishedAt)
- `openDriftCount` — GitOpsConfigs in `drifted` state
- `openPRCount` — open pull request count
### Added — EventBus `Healthy() bool`
- Added to the `EventBus` interface; `NATSBus` returns `nc.IsConnected()`; `NoOpBus` returns `true`
### Changed — Middleware chain
- `observability.Middleware()` added between `Recoverer` and `CORS` (applies to all requests including `/health` and `/metrics`)
---
## [0.7.0] — 2026-05-12
Phase 3D complete. Git is now the source of truth for environment deployment state.
### Added — GitOps Controller (`internal/domain/gitops/`)
- `controller.go` — starts as a background goroutine; subscribes to `push.received`,
`deployment.succeeded`, `deployment.failed`; runs a periodic reconciliation ticker
(interval configurable via `GITOPS_RECONCILE_INTERVAL`); recovers stale `syncing`
configs to `drifted` on startup
- `drift.go``CheckDrift` calls `git rev-parse` via the existing git domain wrapper;
`handlePush` queries all GitOpsConfigs matching the pushed branch and evaluates drift;
`periodicCheck` iterates configs whose `SyncInterval` has elapsed; publishes
`environment.drift_detected` when drift is found
- `reconciler.go``TriggerSync` creates a `Deployment` record and publishes
`deployment.started` (same lifecycle path as manual deployments, `TriggeredBy="gitops"`);
`handleDeploymentSucceeded` resolves open drift events and marks config `synced` for
both GitOps and manual deployments; `handleDeploymentFailed` reverts to `drifted`
### Added — GitOps HTTP API (`internal/api/handlers/gitops.go`)
All routes live under `/api/v1/repos/{owner}/{repo}/environments/{envName}/gitops/`:
- `GET /gitops` — current GitOpsConfig or 404 if not configured
- `PUT /gitops` — idempotent upsert (branch, autoSync, syncInterval)
- `DELETE /gitops` — remove config without deleting deployments
- `POST /gitops/sync` — manual reconciliation trigger; creates deployment record
- `GET /gitops/drift` — current sync status: syncStatus, desiredSha, actualSha, isDrifted
- `GET /gitops/drift/history` — paginated drift event log (newest first)
- `POST /gitops/drift/{driftID}/acknowledge` — acknowledge without syncing
### Added — Database Models (migration `013_gitops`)
- `GitOpsConfig` — links environment to a branch; tracks `DesiredSHA`, `ActualSHA`,
`SyncStatus` (`unknown/synced/drifted/syncing`), `AutoSync`, `SyncInterval`,
`LastCheckedAt`
- `GitOpsDriftEvent` — append-only drift record: `DesiredSHA`, `ActualSHA`,
`SyncStatus` (`drifted/synced/acknowledged`), `DetectedAt`, `ResolvedAt`
### Added — Supporting Changes
- `git.RevParse(repoPath, ref)` — new function in `internal/domain/git/binary.go`
used by `CheckDrift` to resolve branch HEAD SHA
- `events.DeploymentEvent` + `events.DriftEvent` types added to `internal/events/types.go`
- `EnvironmentHandler.publishDeployEvent` updated to use shared `events.DeploymentEvent`
so the GitOps controller can unmarshal deployment lifecycle events correctly
- `GITOPS_RECONCILE_INTERVAL` env var (default `300`s); `0` disables the periodic ticker
- `ArtifactRoot` config field + `ARTIFACT_ROOT` env var
---
## [0.6.0] — 2026-05-12
Phase 3C complete. Multi-tenant workspaces and a full secret management hierarchy operational.
### Added — Workspaces
- `Workspace` model (migration `011`): globally unique handle, display name, description, avatarUrl
- `WorkspaceMember` model: owner/admin/member roles per workspace
- Repository `workspace_id` column (optional; null = personal repo)
- Full workspace CRUD API: `GET/POST /api/v1/workspaces`, `GET/PATCH/DELETE /api/v1/workspaces/{handle}`
- Workspace member management: list, add, update role, remove
- `GET /api/v1/workspaces/{handle}/repos` — repos in workspace
- Workspace frontend: WorkspacesPage, WorkspacePage, workspace switcher in sidebar header
- Workspace owner selector in repo create flow
### Added — Secret Management (`internal/api/handlers/secret.go`)
- `Secret` model (migration `012`): `Scope` (global/workspace/repo/env), `ScopeID`, `Name`,
`EncryptedValue` (AES-256-GCM, never returned by API)
- Unique constraint on (scope, scope_id, name)
- CRUD at all scope levels:
- `GET/POST/DELETE /api/v1/admin/secrets` (global, admin-only)
- `GET/POST/DELETE /api/v1/workspaces/{handle}/secrets` (workspace-scoped)
- `GET/POST/DELETE /api/v1/repos/{owner}/{repo}/secrets` (repo-scoped)
- `GET/POST/DELETE /api/v1/repos/{owner}/{repo}/environments/{envName}/secrets` (env-scoped)
- `ResolveSecretsForRun(db, repoID, workspaceID, envID, sessionSecret)` — hierarchy
resolution for CI executor: Env > Repo > Workspace > Global
- CI executor updated to inject resolved secrets as Docker `--env` flags
- RepoSecretsPage — write-only UI, values never displayed after creation
- Sidebar "Secrets" nav item in repo context
### Completed — Phase 3B (Unified Operational Timeline)
- `GET /api/v1/repos/:owner/:repo/timeline` — merges commits, pipeline runs, and deployments into a single chronological feed
- `RepoTimelinePage` at `/repos/:owner/:repo/timeline` — vertical event feed with type filter tabs
---
## [0.5.0] — 2026-05-11
Phases 3A and 3B complete. Environments, deployments, and the operational timeline are operational.
### Added — Environments + Deployments (Phase 3A)
- `Environment` model (migration `010`): repoId, name, URL, protectionRules (JSON)
- `Deployment` model: envId, repoId, sha, ref, status lifecycle
(`pending → in_progress → success/failure/cancelled`), triggeredBy, description, runId link
- CRUD API for environments: `GET/POST /environments`, `GET/PATCH/DELETE /environments/{envName}`
- Deployment API: `GET/POST /environments/{envName}/deployments`,
`PATCH /environments/{envName}/deployments/{id}/status`
- NATS events published on status transitions: `deployment.started`, `deployment.succeeded`,
`deployment.failed`
- `EnvironmentsPage` — environment cards each showing latest deployment status, SHA, actor,
and time since deploy; deployment history per env
- Sidebar "Environments" nav item in repo context
- Repo page deployment status badges (latest deploy per env at a glance)
### Added — Unified Operational Timeline (Phase 3B)
- `GET /api/v1/repos/{owner}/{repo}/timeline` — merged chronological feed of commits,
pipeline runs, and deployments; default 60 events, max 200
- `RepoTimelinePage` at `/repos/:owner/:repo/timeline` — vertical event feed with type
filter tabs (all / commits / runs / deployments)
- Sidebar "Timeline" nav item between Environments and Settings
- Event types: commit (SHA, message, author), run (status, ref, duration), deployment (env, status, SHA)
- Answers "what changed before things broke?" without navigating between separate pages
### Completed — Phase 3A (Environment model + deployment tracking)
- `Environment` model per repo (name, URL, protection rules)
- `Deployment` model (sha, ref, status, triggered_by, run_id link)
- Full CRUD API for environments
- Deployment trigger + status update API
- NATS event publishing for `deployment.*` subjects
- `EnvironmentsPage` per repo — environment cards with live deployment status
- Deployment history per environment
- Sidebar "Environments" nav item
- Repo page deployment status badges
---
### Completed — Phase 2C (CI Legibility)
- `PipelinesPage` — real cross-repo runs feed with status filter tabs
## [0.4.0] — 2026-05-11
Phase 2C complete. CI results are legible in the UI; the dashboard is an operational command center.
### Added — Pipeline Visualization
- `PipelinesPage` — cross-repo pipeline runs feed with status filter tabs (all / running / failed / succeeded)
- `RepoPipelinesPage` — repo-scoped runs list at `/repos/:owner/:repo/pipelines`
- `PipelineRunPage` — run detail with topological DAG visualization + step log viewer
- `PipelineWaterfall` — rewritten to accept real `PipelineJob[]` data with `needs` graph
- Dashboard CI widget — live recent runs replacing "coming soon" placeholder
- Command palette — pipeline run results + Pipelines quick-nav
- `GET /api/v1/pipelines/runs` — cross-repo recent runs endpoint
- Dashboard `recentRuns[]` field added
- `PipelineRunPage` — run detail with topological DAG visualization using real `PipelineJob[]` +
`needs` graph; step log viewer (collapsible per step, ANSI color, auto-scroll with lock toggle)
- `PipelineWaterfall` — rewritten to accept live job data instead of static mock stages
- `GET /api/v1/pipelines/runs` — cross-repo recent runs for the dashboard
### Planned — Phase 3 (GitOps + Observability + Federation)
- GitOps controller with reconciliation loops
- Environment model + deployment tracking
- Unified operational timeline (commits + deployments + CI failures merged)
- Drift detection and sync status
- Deployment promotion workflows (dev → staging → production)
- Rollback visualization and one-click rollbacks
- Canary and blue/green deployment support
- ActivityPub / ForgeFed federation handlers (inbox, outbox, cross-instance PRs)
- Secret management hierarchy (Global → Org → Repo → Env)
- Observability (Prometheus endpoint, health sparklines)
### Added — Dashboard CI Command Center
- Dashboard CI widget replaced "coming soon" with live recent pipeline runs
- Dashboard `recentRuns[]` field added to the `/api/v1/dashboard` response
### Planned — Phase 4
- AI diagnostics (pipeline failure root-cause analysis)
- Signed artifacts (Sigstore/Cosign)
- OCI package registry
- Secret and dependency vulnerability scanning
### Added — Command Palette Wiring
- Pipeline run results surfaced in command palette results
- "Pipelines" quick-nav action
---
@@ -75,38 +292,35 @@ Phase 2B complete. Full CI/CD execution backend operational.
### Added — CI Orchestrator (`internal/domain/ci/`)
- DAG-based pipeline orchestrator (`orchestrator.go`): subscribes to NATS `push.received`,
parses `.forgebucket/workflows/*.yml`, creates `PipelineRun`/`PipelineJob`/`PipelineStep`
records, advances DAG on `job.completed`/`job.failed`, recovers stale runs on startup
- Docker executor (`executor.go`): runs steps in isolated containers (`docker run --rm`),
streams logs to DB and NATS via `pipeline.log` subject, handles `git archive` workspace extraction
- Runner manager (`runner_manager.go`): semaphore-limited concurrent job dispatch (default 4),
subscribes to `job.queued`, calls executor when Docker is available
- DAG engine (`dag.go`): full topological sort (`TopoSort`) and `ReadyJobs` for dependency resolution
- Workflow parser (`parser.go`): reads `.forgebucket/workflows/*.yml` from git ref,
`MatchesPushTrigger` with glob pattern support
- CI types (`types.go`): `WorkflowFile`, `WorkflowJob`, `WorkflowStep`, YAML `StringOrSlice` unmarshaler
parses `.forgebucket/workflows/*.yml`, creates `PipelineRun/Job/Step` records, advances
DAG on `job.completed/failed`, recovers stale runs on startup
- Docker executor (`executor.go`): steps run in isolated containers (`docker run --rm`),
logs stream to DB and NATS via `pipeline.log`, workspace extracted via `git archive`
- Runner manager (`runner_manager.go`): semaphore-limited (default 4 concurrent),
subscribes to `job.queued`, skips gracefully if Docker is unavailable
- DAG engine (`dag.go`): `TopoSort`, `ReadyJobs`
- Workflow parser (`parser.go`): `.forgebucket/workflows/*.yml` from git ref,
`MatchesPushTrigger` with glob branch patterns; `StringOrSlice` YAML unmarshaler
### Added — CI API Handlers
- `GET /api/v1/repos/:owner/:repo/pipelines` list pipeline definitions
- `GET /api/v1/repos/:owner/:repo/runs` list pipeline runs (most recent first, limit 30)
- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with full job + step tree
- `POST /api/v1/repos/:owner/:repo/runs/:runID/cancel` — cancel queued or running run
- `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry` — re-queue failed/cancelled job
- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step-level log chunks
- `GET /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — list artifacts for a run
- `POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — upload artifact (multipart, 512 MB max)
- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — artifact download with path traversal guard
- `GET /api/v1/admin/runners` — list registered runners (admin-only)
- `POST /api/v1/admin/runners/register` — register a new runner with bcrypt token hashing (admin-only)
- `GET /api/v1/repos/:owner/:repo/pipelines` — pipeline definitions
- `GET /api/v1/repos/:owner/:repo/runs` — pipeline runs (newest first)
- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with job + step tree
- `POST /api/v1/repos/:owner/:repo/runs/:runID/cancel`
- `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry`
- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step log chunks
- `GET/POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts`
- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — path-traversal guarded
- `GET/POST /api/v1/admin/runners` — runner list + registration (admin-only, bcrypt token)
### Added — Database Models (migration `009_ci`)
- `Pipeline` — workflow definition record (name, filePath, repoId)
- `PipelineRun` — execution record (triggerRef, triggerSha, triggeredBy, status, startedAt, finishedAt)
- `PipelineJob` — single DAG node (name, image, needs JSON, status, timing)
- `PipelineStep` — single command within a job (seq, runCmd, usesAction, exitCode, timing)
- `PipelineStepLog` — append-only log chunk storage (stepId, chunkIndex, content)
- `Runner` — registered execution backend (name, labels, status, tokenHash, lastSeenAt)
- `Artifact` — build artifact (runId, repoId, name, storagePath, size, contentType)
- `Pipeline`, `PipelineRun`, `PipelineJob`, `PipelineStep`, `PipelineStepLog`
- `Runner` (name, labels, status, tokenHash, lastSeenAt)
- `Artifact` (runId, repoId, name, storagePath, size, contentType)
### Changed — Git HTTP handler
- `parseAndCheckBody` replaces `checkProtectionsFromBody` — now also returns parsed
`refUpdate` structs for publishing `push.received` after each successful receive-pack
---
@@ -116,105 +330,84 @@ Phase 2A complete. Real-time event infrastructure and audit log operational.
### Added — NATS Event Bus (`internal/events/`)
- `EventBus` interface: `Publish`, `Subscribe`, `Close`
- `NATSBus`: NATS-backed implementation with auto-reconnect, max-reconnect disabled
- `NoOpBus`: silent fallback when `NATS_URL` is not configured (app fully functional without NATS)
- `New(url)` factory: returns `NATSBus` if URL is set, `NoOpBus` otherwise
- Event subjects defined in `subjects.go`:
- `repo.*` (created, deleted, pushed)
- `push.received`
- `pr.*` (opened, merged, closed)
- `issue.*` (opened, closed)
- `pipeline.*` (queued, started, succeeded, failed, cancelled)
- `job.*` (queued, started, completed, failed), `pipeline.log`
- `deployment.*`, `environment.*` (Phase 3 stubs)
- `audit.event`
- `NATSBus`: NATS-backed with auto-reconnect; `NoOpBus` fallback when `NATS_URL` unset
- `New(url)` factory: returns `NATSBus` or `NoOpBus`
- 40+ event subjects in `subjects.go` covering repo, push, PR, issue, pipeline, job,
deployment, environment, and audit namespaces
### Added — WebSocket Hub (`internal/api/handlers/ws.go`)
- `GET /ws`upgrades HTTP to WebSocket (nhooyr.io/websocket)
- Subscribes to all NATS subjects on connect, fans events to the client as JSON
- Optional session auth (`auth.Optional` middleware) — works for guests too
- Phase 2B note: per-user event filtering is a planned upgrade
### Added — WebSocket Hub
- `GET /ws`NATS wildcard subscription (`>`) fans all events to connected clients as JSON
- `{ subject, payload }` envelope format
- Goroutine per client with buffered send channel (64 events); slow clients drop events
### Added — Audit Log
- `AuditLog` model (migration `008_audit_log`): actor, method, path, statusCode, requestBody, ipAddr, timestamp
- `AuditLog` middleware: records every authenticated request to the DB and publishes `audit.event`
- `GET /api/v1/audit` — paginated audit log query (admin-only, filterable by actor/method/time range)
### Fixed — Local development environment
- `DATABASE_URL` was using Docker-internal hostname `postgres`; corrected to `localhost` for `make dev`
- Added `NATS_URL=nats://localhost:4222` to `.env` (was missing; CI orchestrator requires it)
- `REPO_ROOT` corrected to `/tmp/forgebucket/repos` (Docker path `/var/lib/forgebucket/repos` requires sudo on macOS)
### Added — Audit Log (migration `008_audit_log`)
- `AuditLog` model: actorId, actorName, method, path, statusCode, ipAddress, userAgent
- Middleware records every POST/PUT/PATCH/DELETE in the protected route group
- Writes DB row + publishes `audit.event` asynchronously (never blocks the response)
- `GET /api/v1/audit` — paginated, filterable by actor/method/since (admin-only)
---
## [0.1.0] — 2026-05-11
Initial development milestone. Core Git hosting, collaboration, and frontend SPA are functional.
Initial development milestone. Core Git hosting, collaboration, and frontend SPA functional.
### Added — Authentication & Security
- User registration and login with secure session cookies
- CSRF protection on all mutating routes via `X-CSRF-Token` header
- Middleware chain: Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → Handler
- CSRF protection via double-submit cookie pattern (`X-CSRF-Token`)
- SSH key management per user
- OIDC / OAuth2 optional integration (configurable via env)
- Scoped access tokens with optional expiration dates
- Repository deploy keys (read-only or read-write HTTP tokens)
- ENV-driven config with fail-fast validation on missing secrets
- OIDC / OAuth2 optional integration
- Scoped access tokens with optional expiration
- Repository deploy keys (read-only or read-write)
- ENV-driven config with fail-fast on missing secrets
### Added — Git Hosting
- Smart HTTP transport (git clone, push, pull over HTTP)
- AGit protocol support (`refs/for/` push for instant PR creation without branch switching)
- Branch management (list, create, delete, default branch configuration)
- Commit log and diff viewing
- Git LFS per-repository (configurable file size limits, locking)
- Branch protection rules (force-push blocking, required reviews)
- Smart HTTP transport (clone, push, pull over HTTP)
- AGit protocol (`refs/for/` push for instant PR creation)
- Branch management, commit log, diff viewing
- Git LFS per-repository (configurable file size limits)
- Branch protection rules (force-push blocking)
- Repository visibility (public / private)
### Added — Collaboration
- Pull requests (open / merged / closed states) with author tracking
- Pull requests (open / merged / closed) with author tracking
- Issues (open / closed)
- Reviewer assignment (default reviewer per repo, per-PR reviewer assignment)
- Merge strategy selection per repository (merge commit / squash / rebase)
- Reviewer assignment (default reviewer per repo, per-PR overrides)
- Merge strategy selection per repository (merge / squash / rebase)
- Branching model configuration (feature / bugfix / release / hotfix prefixes)
- PR default description templates (per-repo)
- Excluded files from diffs (glob pattern configuration)
- PR default description templates + excluded-files configuration
- Webhook system with event filtering (push, pull_request, issue)
- Repository member RBAC (read / write / admin roles)
- Repository member RBAC (read / write / admin)
### Added — Frontend SPA
- React 18 + TypeScript + Vite, embedded into Go binary via `//go:embed`
- 20 route-level pages: Login, Register, Dashboard, Repos, CreateRepo, ImportRepo, Repo,
RepoSettings, Blob, Commits, Branches, RepoIssues, RepoPRs, CreatePR, PRDetail, Starred,
PRs (cross-repo), Pipelines (placeholder), Explore, Profile, Settings
- AppShell layout wrapper for all authenticated pages
- 20 route-level pages covering auth, dashboard, repos, code, PRs, issues, and settings
- Triple-state sidebar: expanded (320px) / collapsed (56px) / mobile bottom bar
- Mobile-first responsive design (375px → 1440px)
- DiffViewer: side-by-side and unified views with syntax highlighting
- MobileComment: bottom-sheet overlay for inline code review on mobile
- TreeBrowser: repository file tree navigation
- PipelineWaterfall: placeholder pipeline visualization component
- Skeleton loading states for perceived performance
- DiffViewer (side-by-side + unified), MobileComment (bottom-sheet), TreeBrowser
### Added — Design System
- Custom semantic token palette in `frontend/src/ui/tokens.ts`
- Full dark/light mode support via Tailwind CSS v4 `@variant dark`
- Brand colors: `#0052CC` (light) / `#3B82F6` (dark)
- 8px grid system (xs: 4px, sm: 8px, md: 16px, lg: 24px, xl: 32px, xxl: 48px)
- 44px minimum touch targets on all interactive elements (WCAG 2.5.5)
- Consistent border radius scale (subtle 38px, full 9999px)
- Full dark/light mode via Tailwind CSS v4 `@variant dark`
- 8px grid system; 44px minimum touch targets (WCAG 2.5.5)
- System font stack (Segoe UI, Roboto, sans-serif)
### Added — Infrastructure
- PostgreSQL + XORM with 7 migration files covering: users, repositories, issues, SSH keys,
access tokens, deploy keys, workflows, and LFS settings
- ActivityPub actor data model (FederationActor with inbox/outbox URLs and RSA key pairs) — data layer only
- Docker Compose setup for local PostgreSQL + NATS
- Makefile targets: dev, build, migrate, test, lint, docker-up
- WebSockets foundation for live logs and notifications
- PostgreSQL + XORM with migrations 001007
- ActivityPub actor data model (FederationActor) — data layer only
- Docker Compose for local PostgreSQL + NATS
- Makefile: dev, build, migrate, test, lint, docker-up
---
[Unreleased]: https://github.com/forgeo/forgebucket/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/forgeo/forgebucket/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/forgeo/forgebucket/compare/v0.9.0...v1.0.0
[0.9.0]: https://github.com/forgeo/forgebucket/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/forgeo/forgebucket/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/forgeo/forgebucket/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/forgeo/forgebucket/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/forgeo/forgebucket/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/forgeo/forgebucket/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/forgeo/forgebucket/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/forgeo/forgebucket/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/forgeo/forgebucket/releases/tag/v0.1.0
+88 -46
View File
@@ -4,7 +4,7 @@
ForgeBucket is a self-hosted, federated developer operations platform. Where other Git platforms show you a list of files, ForgeBucket surfaces deployments, pipeline health, environment drift, and operational context directly alongside your code. Repositories are runtime systems. The dashboard is a command center.
**Status:** Phase 2C in progress. CI/CD execution backend is fully operational. Pipeline visualization and dashboard integration are being wired up now.
**Status:** Active development. Phase 4 (signed artifacts, SBOM, secret/dependency scanning, OCI registry) complete. Phase 5 (AI diagnostics) is next.
---
@@ -32,7 +32,8 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
| OIDC / OAuth2 (optional) | Done |
| Access tokens (scoped, expiring) | Done |
| Deploy keys | Done |
| Audit log | Done |
| Audit log (admin-only, filterable) | Done |
| Workspaces (multi-tenant namespaces) | Done |
### Git Hosting
| Feature | Status |
@@ -59,44 +60,62 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
### CI/CD
| Feature | Status |
|---------|--------|
| CI orchestrator (DAG pipeline execution) | Done (Phase 2B) |
| Runner manager (Docker backend) | Done (Phase 2B) |
| Build artifact storage | Done (Phase 2B) |
| Pipeline cancellation + job retry | Done (Phase 2B) |
| NATS event bus + WebSocket live push | Done (Phase 2A) |
| Pipeline DAG visualization (frontend) | Done (Phase 2C) |
| Dashboard CI command center | Done (Phase 2C) |
| Pipeline log viewer (per-step, collapsible) | Done (Phase 2C) |
| Kubernetes / Firecracker runner backends | Planned (Phase 2D) |
| Forgejo Actions gRPC integration | Planned |
| NATS event bus + WebSocket live push | Done |
| CI orchestrator (DAG pipeline execution) | Done |
| Runner manager (Docker backend) | Done |
| Build artifact storage + download | Done |
| Pipeline cancellation + job retry | Done |
| Pipeline log streaming (per-step, NATS) | Done |
| Pipeline DAG visualization (frontend) | Done |
| Dashboard CI command center | Done |
| Pipeline log viewer (collapsible, per-step) | Done |
| SBOM auto-generation on pipeline success | Done |
| Per-run SBOM download on pipeline detail page | Done |
| Kubernetes / Firecracker runner backends | Planned |
| Matrix builds + reusable workflow templates | Planned |
| Flaky test detection | Planned |
### GitOps + Environments
### Environments + GitOps
| Feature | Status |
|---------|--------|
| Environment model + deployment tracking | **In progress (Phase 3A)** |
| Unified operational timeline | Planned (Phase 3B) |
| Secret management hierarchy | Planned (Phase 3C) |
| GitOps controller + drift detection | Planned (Phase 3D) |
| Deployment promotion workflows | Planned (Phase 3D) |
| Rollback visualization | Planned (Phase 3D) |
| Canary / blue-green support | Planned (Phase 3D) |
| Environment model + deployment tracking | Done |
| Deployment status lifecycle API | Done |
| Unified operational timeline | Done |
| Secret management (Global → Workspace → Repo → Env) | Done |
| GitOps controller (drift detection + auto-sync) | Done |
| Deployment promotion workflows | Planned |
| Rollback visualization | Planned |
| Canary / blue-green support | Planned |
### Observability + Security
| Feature | Status |
|---------|--------|
| Prometheus endpoint + health sparklines | Planned (Phase 3E) |
| Secret scanning | Planned (Phase 4) |
| Dependency scanning | Planned (Phase 4) |
| Signed artifacts (Sigstore/Cosign) | Planned (Phase 4) |
| `GET /health` — structured DB + NATS liveness check | Done |
| `GET /metrics` — Prometheus endpoint (HTTP + platform metrics) | Done |
| HTTP instrumentation middleware (latency histogram, request counter) | Done |
| Per-repo operational health summary (`GET /repos/.../health`) | Done |
| NATS-driven pipeline + deployment counters | Done |
| SBOM generation (CycloneDX 1.4, auto on pipeline success) | Done |
| Secret scanning (15 regex patterns, push-triggered) | Done |
| Dependency vulnerability scanning (OSV API backed) | Done |
| Signed artifacts (ECDSA P-256, self-verifying bundles) | Done |
| OCI Distribution Spec v1.1 registry | Done |
| Unified repo Security page (SBOM + leaks + vulns) | Done |
| Health sparklines in repo/env pages (frontend) | Planned |
### Federation
| Feature | Status |
|---------|--------|
| ActivityPub actor model | Done (data layer) |
| Federation handlers / inbox / outbox | Planned (Phase 3F) |
| Cross-instance pull requests | Planned (Phase 3F) |
| ActivityPub actor model | Done |
| WebFinger (`/.well-known/webfinger`) | Done |
| Actor documents (`/users/{username}`) | Done |
| Inbox (receive + HTTP signature verify) | Done |
| Outbox (OrderedCollection, paginated) | Done |
| Followers / Following collections | Done |
| HTTP signatures (draft-cavage-http-signatures) | Done |
| Follow / Accept auto-accept flow | Done |
| RSA-2048 key pair lazy generation | Done |
| Cross-instance pull requests (ForgeFed) | Planned |
---
@@ -120,7 +139,7 @@ make dev
The Go API runs at `http://localhost:8080`. The Vite dev server runs at `http://localhost:5173` and proxies API requests.
> **Local dev note:** `DATABASE_URL` must use `localhost` (not `postgres`) and `NATS_URL` must be set to `nats://localhost:4222`. The `.env` file ships with correct defaults for local development. See `.env.example` for all variables.
> **Docker note:** CI execution requires the Docker daemon to be running. If Docker is unavailable, the runner manager logs a warning and disables CI; the rest of the platform works normally.
---
@@ -128,22 +147,32 @@ The Go API runs at `http://localhost:8080`. The Vite dev server runs at `http://
```
ForgeBucket
├── API Gateway (Chi router, internal/api/)
├── API Gateway (Chi router internal/api/router.go)
├── Auth Service (sessions, CSRF, OIDC — internal/api/handlers/)
├── Repository Service (git HTTP, branches, LFS — internal/domain/git/)
├── Pull Request Service (PRs, reviews, merge — internal/api/handlers/)
├── Issue Service (issues, labels — internal/api/handlers/)
├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/) ← Phase 2B done
├── Event Bus (NATS core, NoOp fallback — internal/events/) ← Phase 2A done
├── Federation Layer (ActivityPub actors — internal/domain/federation/) ← Phase 3F stub
├── Secret Manager (env-based, scoped tokens — internal/config/)
├── Database (PostgreSQL + XORM — internal/models/)
── Web Frontend (React 18 + TypeScript, embedded via //go:embed — web/)
├── Issue Service (issues — internal/api/handlers/)
├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/)
├── GitOps Controller (drift detection, auto-sync — internal/domain/gitops/)
├── Observability (Prometheus metrics, health — internal/observability/)
├── Environment Service (environments, deployments — internal/api/handlers/environment.go)
├── Secret Manager (scoped AES-256-GCM — internal/api/handlers/secret.go)
── Workspace Service (multi-tenant namespaces — internal/api/handlers/workspace.go)
├── SBOM Generator (CycloneDX 1.4, auto on pipeline success — internal/domain/sbom/)
├── Secret Scanner (15 push-triggered regex patterns — internal/domain/scanning/)
├── Vulnerability Scanner (OSV API-backed dependency scanning — internal/domain/vulnscan/)
├── Artifact Signing (ECDSA P-256 self-verifying bundles — internal/domain/signing/)
├── OCI Registry (Distribution Spec v1.1 blob store — internal/domain/oci/)
├── Event Bus (NATS core, NoOp fallback — internal/events/)
├── Audit Log (every mutating request — internal/api/middleware/audit.go)
├── Federation Layer (ActivityPub inbox/outbox, HTTP signatures — internal/domain/federation/)
├── Database (PostgreSQL + XORM 20 migrations — internal/models/)
└── Web Frontend (React 18 + TypeScript, //go:embed — web/)
```
**Middleware chain (every request):**
```
Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → AuditLog → Handler
Logger → RealIP → Recoverer → Metrics → CORS → CSRF → SessionAuth → AuditLog → Handler
```
---
@@ -155,15 +184,21 @@ Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → A
| Language | Go 1.21+ |
| Router | Chi |
| ORM / Migrations | XORM + PostgreSQL |
| Event bus | NATS (core; JetStream planned for Phase 2B durability) |
| Real-time | WebSockets (nhooyr.io/websocket) |
| CI execution | Docker (`docker run --rm`) |
| Event bus | NATS core (`github.com/nats-io/nats.go`) |
| Real-time | WebSockets (`nhooyr.io/websocket`) |
| CI execution | Docker (`docker run --rm` via `exec.Command`) |
| Frontend framework | React 18 + TypeScript |
| Build tool | Vite |
| Styling | Tailwind CSS v4 |
| YAML parsing | `gopkg.in/yaml.v3` (workflow definitions) |
| Code editing | CodeMirror |
| Container | Docker Compose (dev) |
| Federation | ActivityPub / ForgeFed (data layer only) |
| Federation | ActivityPub / ForgeFed (WebFinger, actor, inbox/outbox, HTTP signatures) |
| SBOM format | CycloneDX 1.4 (JSON) |
| Vulnerability data | OSV API (`api.osv.dev`) |
| Secret detection | Regex-based (15 patterns, push-triggered) |
| Artifact signing | ECDSA P-256 (ASN.1 DER, self-verifying bundles) |
| OCI storage | On-disk content-addressable blob store (Distribution Spec v1.1) |
---
@@ -186,12 +221,16 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string — use `localhost` for local dev |
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `SESSION_SECRET` | Yes | Session signing key, ≥ 32 chars (`openssl rand -hex 32`) |
| `CSRF_SECRET` | Yes | CSRF key, exactly 32 chars (`openssl rand -hex 16`) |
| `PORT` | No | HTTP port, default `8080` |
| `REPO_ROOT` | Yes | Absolute path for bare git repository storage |
| `NATS_URL` | No | NATS connection URL (e.g. `nats://localhost:4222`). If unset, CI runs in no-op mode |
| `ARTIFACT_ROOT` | No | Artifact storage path, defaults to `../artifacts` relative to `REPO_ROOT` |
| `NATS_URL` | No | NATS connection URL (e.g. `nats://localhost:4222`). If unset, event bus is no-op |
| `GITOPS_RECONCILE_INTERVAL` | No | Seconds between periodic drift checks, default `300`. `0` disables the ticker |
| `OCI_ROOT` | No | Root directory for OCI Distribution Spec blob and upload storage, defaults to `../oci` relative to `REPO_ROOT` |
| `ARTIFACT_SIGNING_KEY` | No | Path to ECDSA P-256 PEM for artifact signing; auto-generates ephemeral key if unset (warns on restart) |
| `INSTANCE_URL` | Yes | Public URL of this instance (no trailing slash) |
| `INSTANCE_NAME` | No | Display name, default `ForgeBucket` |
| `OIDC_ISSUER` | No | OIDC provider URL |
@@ -224,9 +263,12 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
| Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | Done |
| Phase 3A | Environment model + deployment tracking | Done |
| Phase 3B | Unified operational timeline | Done |
| Phase 3C | Workspaces + secret management hierarchy | **In progress** |
| Phase 3DF | GitOps/drift, federation, observability | Planned |
| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned |
| Phase 3C | Workspaces + secret management hierarchy (Global → Workspace → Repo → Env) | Done |
| Phase 3D | GitOps controller + drift detection + auto-sync | Done |
| Phase 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | Done |
| Phase 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures) | Done |
| Phase 4 | Signed artifacts, SBOM, OCI registry, secret/dep scanning, security page | Done |
| Phase 5 | AI diagnostics, deployment promotions, rollback visualization | Next |
---
@@ -1,741 +0,0 @@
# MASTER IMPLEMENTATION PROMPT
## Building a Modern Git Platform with CI/CD + GitOps + Operational UX
You are an elite senior staff engineer, platform architect, DevOps architect, distributed systems engineer, and product UX engineer.
You are tasked with designing and implementing a next-generation self-hosted Git platform.
This platform exists in the category of:
- GitHub
- GitLab
- Forgejo
- Gitea
- Bitbucket
BUT:
You must NOT merely clone existing platforms.
The goal is to create:
> a modern developer operations platform
that deeply integrates:
- Git hosting
- pull requests
- CI/CD
- GitOps
- deployments
- environments
- observability
- security
- operational awareness
- developer productivity
The platform should feel:
- fast
- operational
- developer-first
- low cognitive load
- modern
- reliable
- modular
- keyboard-first
- context-aware
The product philosophy is:
> repositories are operational systems
NOT:
> collections of files.
==================================================
HIGH LEVEL PRODUCT GOALS
==================================================
The platform should optimize for:
- immediate situational awareness
- workflow continuity
- deployment visibility
- operational clarity
- reliability
- developer flow
- observability
- intelligent automation
- security by default
- excellent UX
Users should always be able to answer:
- what changed?
- what failed?
- what deployed?
- what is unhealthy?
- what needs my attention?
- what environments are affected?
- what should I review next?
- what caused this incident?
without navigating deeply.
==================================================
CORE PRODUCT PRINCIPLES
==================================================
1. UX FIRST
Most Git platforms prioritize functionality over usability.
This platform must prioritize:
- readability
- discoverability
- contextual awareness
- progressive disclosure
- speed
- low noise
- operational understanding
Avoid:
- tab overload
- enterprise clutter
- giant forms
- notification spam
- YAML-centric UX
- log-centric UX
The system should feel:
- intentional
- calm
- efficient
- operationally intelligent
--------------------------------------------------
2. OPERATIONAL AWARENESS
The platform must continuously surface:
- failing pipelines
- deployment health
- environment drift
- flaky tests
- security issues
- review bottlenecks
- dependency risks
- deployment risks
This information should appear naturally throughout the UI.
--------------------------------------------------
3. REPOSITORIES ARE RUNTIME SYSTEMS
The repository page should not simply display files.
It should display:
- deployments
- environments
- active PRs
- operational health
- timelines
- incidents
- ownership
- risk
- observability
The file tree is secondary.
--------------------------------------------------
4. GITOPS IS FIRST CLASS
Git should become:
- source of truth
- deployment history
- environment state definition
- rollback system
- audit log
The system must support:
- reconciliation
- drift detection
- declarative environments
- promotion workflows
- rollback visualization
- environment topology
--------------------------------------------------
5. RELIABILITY IS A PRIMARY FEATURE
The platform should optimize for:
- deterministic execution
- idempotency
- isolation
- resilience
- queue durability
- observability
- safe rollbacks
- fault tolerance
==================================================
ARCHITECTURE REQUIREMENTS
==================================================
Design the platform using modular services.
Suggested architecture:
```txt
Platform
├── API Gateway
├── Auth Service
├── Repository Service
├── Pull Request Service
├── Issue Service
├── Event Bus
├── CI Orchestrator
├── Runner Manager
├── Artifact Registry
├── Package Registry
├── Deployment Engine
├── GitOps Controller
├── Environment Service
├── Secret Manager
├── Notification Service
├── Search Service
├── Observability Layer
├── Metrics Service
├── Audit Service
└── Web Frontend
```
==================================================
EVENT DRIVEN ARCHITECTURE
==================================================
The platform should be event-driven.
Everything emits events.
Examples:
```txt
repo.created
push.created
pr.opened
review.requested
pipeline.started
pipeline.failed
artifact.published
deployment.started
deployment.failed
environment.drift_detected
incident.created
```
Requirements:
- durable events
- replay support
- auditability
- timeline reconstruction
- reactive UI updates
Recommended technologies:
- NATS
- Kafka
- RabbitMQ
Prefer NATS initially for simplicity.
==================================================
CI/CD SYSTEM REQUIREMENTS
==================================================
The CI/CD system must support:
- DAG pipelines
- matrix builds
- reusable workflows
- workflow templates
- conditional execution
- parallel execution
- artifacts
- caches
- retries
- concurrency controls
- cancellation
- pipeline graphs
- environment promotion
- rollback support
- preview environments
- flaky test detection
- deployment visualization
The orchestrator should:
- schedule
- coordinate
- monitor
NOT directly execute jobs.
==================================================
RUNNER SYSTEM REQUIREMENTS
==================================================
Runners should:
- be ephemeral
- isolated
- disposable
- reproducible
- stateless
Support execution backends:
1. Docker containers
2. Kubernetes jobs
3. Firecracker microVMs
4. Bare metal runners
Security is critical.
Forked PRs must never automatically receive secrets.
==================================================
SECRET MANAGEMENT
==================================================
Implement scoped secret management.
Secret hierarchy:
```txt
Global
→ Organization
→ Repository
→ Environment
→ Runtime ephemeral injection
```
Support:
- Vault
- OIDC federation
- cloud secret providers
- encrypted secret storage
Secrets must:
- never leak to logs
- be masked automatically
- support audit trails
- support rotation
==================================================
ARTIFACT + PACKAGE MANAGEMENT
==================================================
Implement:
- build artifact storage
- OCI registry
- package registries
- retention policies
- artifact provenance
- signing support
- SBOM support
Support:
- container images
- npm
- cargo
- pip
- maven
- generic artifacts
==================================================
GITOPS REQUIREMENTS
==================================================
GitOps must be deeply integrated.
Support:
- reconciliation loops
- drift detection
- sync visualization
- environment topology
- deployment promotion
- rollback flows
- canary releases
- blue/green deployments
- environment history
The UI should make GitOps understandable.
Avoid overwhelming Kubernetes-centric UX.
==================================================
DATABASE + STORAGE DESIGN
==================================================
Separate:
- application code
- repository storage
- artifacts
- caches
- logs
- uploads
Repository storage should NOT exist in the app repository.
Recommended:
```txt
/data/repos
/data/artifacts
/data/cache
/data/uploads
```
Repository storage is runtime instance data.
Treat it like:
- database files
- object storage
- runtime state
==================================================
DASHBOARD REQUIREMENTS
==================================================
The dashboard should behave like:
> an operational command center
NOT:
> a repository list.
Include:
1. Attention Required
- failing pipelines
- stale PRs
- security alerts
- deployment failures
- environment drift
2. Active Workspaces
- active repos
- active branches
- assigned reviews
- recent deployments
3. Team Activity
- merges
- releases
- deployments
- incidents
4. CI/CD Overview
- pipeline health
- queue health
- flaky tests
- deployment status
5. Review Dashboard
- pending reviews
- high risk PRs
- stale reviews
6. Security Overview
- dependency vulnerabilities
- secret leaks
- suspicious pushes
==================================================
REPOSITORY PAGE REQUIREMENTS
==================================================
The repository page must feel operational.
Top area should include:
```txt
Repo Name
Production: Healthy
CI: Passing
Deployments: 3 active
Risk: Medium
```
The page should include:
- active PRs
- deployments
- environments
- repository health
- security alerts
- ownership
- recent activity
- operational timeline
- preview environments
- CI/CD overview
- deployment history
The README should not dominate the page.
==================================================
UNIFIED OPERATIONAL TIMELINE
==================================================
Implement a unified timeline merging:
- commits
- deployments
- incidents
- rollbacks
- CI failures
- security events
- alerts
- releases
This is one of the most important features.
Users should easily answer:
> what changed before things broke?
==================================================
PIPELINE UX REQUIREMENTS
==================================================
Pipelines should be visual DAGs.
Support:
- dependency visualization
- execution timing
- bottleneck detection
- retry controls
- artifact visibility
- live execution state
- grouped logs
- semantic errors
Logs should support:
- filtering
- collapsing
- syntax highlighting
- structured parsing
- AI summarization
==================================================
DEPLOYMENT UX REQUIREMENTS
==================================================
Deployments must feel first-class.
Each environment should expose:
```txt
Production
Healthy
v1.4.2
3 pods
0.1% errors
Last deploy 14m ago
```
Support:
- rollback
- traffic shifting
- canary visibility
- deployment timelines
- release notes
- environment logs
- health checks
==================================================
COMMAND PALETTE REQUIREMENTS
==================================================
Implement a global command palette.
Inspired by:
- VS Code
- Raycast
- Linear
Examples:
```txt
/retry failed jobs
/deploy staging
/open logs
/review next
/show flaky tests
/open production incidents
```
Keyboard-first navigation is critical.
==================================================
AI-ASSISTED FEATURES
==================================================
Implement optional AI-assisted operational tooling.
Examples:
- failure diagnosis
- flaky test detection
- architecture summaries
- impact analysis
- deployment risk scoring
- review summaries
- onboarding explanations
Example:
```txt
Likely failure cause:
Database migration introduced lock contention.
```
==================================================
OBSERVABILITY REQUIREMENTS
==================================================
Integrate:
- metrics
- traces
- logs
- deployment state
- incident state
- service topology
Do not isolate observability into external systems.
Expose operational health directly in repository pages.
==================================================
RELIABILITY REQUIREMENTS
==================================================
Implement:
- durable queues
- retries
- dead-letter queues
- resumable pipelines
- distributed scheduling
- concurrency controls
- checkpointing
- idempotent operations
The platform should survive:
- runner crashes
- orchestrator crashes
- network partitions
- deployment interruptions
==================================================
SECURITY REQUIREMENTS
==================================================
Implement:
- signed artifacts
- provenance verification
- branch protections
- permission scopes
- audit logging
- secret scanning
- dependency scanning
- runner isolation
- sandboxing
Support:
- Sigstore
- Cosign
- SLSA concepts
==================================================
UI/UX DESIGN LANGUAGE
==================================================
The UI should feel:
- modern
- minimal
- operational
- clean
- responsive
- keyboard-first
- information dense but calm
Visual inspirations:
- Linear
- Datadog
- Grafana
- Raycast
- Arc Browser
- VS Code
- modern observability dashboards
Avoid:
- clutter
- giant tables
- excessive modal workflows
- deeply nested navigation
==================================================
IMPLEMENTATION EXPECTATIONS
==================================================
You should:
- think deeply about architecture
- optimize for maintainability
- optimize for extensibility
- prioritize UX heavily
- prioritize reliability heavily
- explain tradeoffs
- avoid overengineering early
- support future scalability
You should continuously ask:
- does this reduce cognitive load?
- does this improve operational awareness?
- does this improve developer flow?
- does this improve reliability?
- does this make debugging easier?
==================================================
DELIVERABLES
==================================================
When implementing features:
Provide:
- architecture reasoning
- schema design
- API design
- event definitions
- service boundaries
- UI mockups
- workflow diagrams
- UX rationale
- security implications
- scaling considerations
- operational implications
Always optimize for:
- clarity
- reliability
- developer experience
- operational intelligence
- future extensibility
The platform should ultimately feel like:
> a unified operating system for software delivery
rather than:
> a Git repository viewer with CI attached.
+47 -2
View File
@@ -19,7 +19,14 @@ import (
"github.com/forgeo/forgebucket/internal/db"
"github.com/forgeo/forgebucket/internal/domain/ci"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/domain/gitops"
"github.com/forgeo/forgebucket/internal/domain/oci"
"github.com/forgeo/forgebucket/internal/domain/scanning"
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
"github.com/forgeo/forgebucket/internal/domain/sbom"
"github.com/forgeo/forgebucket/internal/domain/signing"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/observability"
"github.com/forgeo/forgebucket/internal/models/migrations"
"github.com/forgeo/forgebucket/web"
)
@@ -48,6 +55,12 @@ func main() {
log.Fatalf("artifact root: %v", err)
}
ociRegistry, err := oci.New(cfg.OCIRoot)
if err != nil {
log.Fatalf("oci: %v", err)
}
log.Printf("oci: registry initialised at %s", cfg.OCIRoot)
bus, err := events.New(cfg.NATSUrl)
if err != nil {
log.Fatalf("events: %v", err)
@@ -67,13 +80,45 @@ func main() {
ciCtx, ciCancel := context.WithCancel(context.Background())
defer ciCancel()
orchestrator := ci.NewOrchestrator(engine, bus)
sbomGen := sbom.NewGenerator(engine, bus)
go sbomGen.Start(ciCtx)
orchestrator := ci.NewOrchestrator(engine, bus, sbomGen)
go orchestrator.Start(ciCtx)
runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4)
go runnerMgr.Start(ciCtx)
handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS())
gitopsCtrl := gitops.NewController(engine, bus, cfg)
go gitopsCtrl.Start(ciCtx)
go observability.StartNATSWatcher(ciCtx, bus)
// Initialise artifact signing key store.
var keyStore *signing.KeyStore
if cfg.ArtifactSigningKey != "" {
keyStore, err = signing.New(cfg.ArtifactSigningKey)
if err != nil {
log.Fatalf("signing: %v", err)
}
} else {
keyStore, err = signing.Generate()
if err != nil {
log.Fatalf("signing: %v", err)
}
}
log.Printf("signing: key store initialised (keyId=%s)", keyStore.KeyID())
secretScanner, err := scanning.New(engine, bus)
if err != nil {
log.Fatalf("secret scanner: %v", err)
}
go secretScanner.Start(ciCtx)
vulnScanner := vulnscan.NewScanner(engine, bus)
go vulnScanner.Start(ciCtx)
handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS(), *keyStore, sbomGen, ociRegistry, secretScanner, vulnScanner)
srv := &http.Server{
Addr: fmt.Sprintf(":%s", cfg.Port),
+21 -4
View File
@@ -1,21 +1,36 @@
services:
postgres:
image: postgres:18.3
container_name: fb-postgres
restart: unless-stopped
environment:
POSTGRES_DB: forgebucket
POSTGRES_USER: forgebucket
POSTGRES_PASSWORD: forgebucket
volumes:
- postgres_data:/var/lib/postgresql
- fb_pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U forgebucket"]
interval: 5s
timeout: 5s
retries: 10
nats:
image: mirror.gcr.io/nats:2-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
ports:
- "4222:4222" # client connections
# "8222:8222" # monitoring HTTP
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
interval: 5s
timeout: 5s
retries: 10
app:
build: .
container_name: fb-app
restart: unless-stopped
depends_on:
postgres:
@@ -26,8 +41,10 @@ services:
ports:
- "8080:8080"
volumes:
- repo_data:/var/lib/forgebucket/repos
- fb_repo_data:/tmp/forgebucket/repos
- fb_oci_data:/tmp/forgebucket/oci
volumes:
postgres_data:
repo_data:
fb_pg_data:
fb_repo_data:
fb_oci_data:
+9 -6
View File
@@ -1,16 +1,14 @@
version: "3.9"
# Dev: only PostgreSQL runs here. Run the Go server locally with `make dev`.
# Production: docker compose -f docker-compose.prod.yml up
services:
nats:
image: nats:2-alpine
image: mirror.gcr.io/nats:2-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
ports:
- "4222:4222" # client connections
- "8222:8222" # monitoring HTTP
- "4222:4222" # client connections
- "8222:8222" # monitoring HTTP
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
interval: 5s
@@ -18,7 +16,7 @@ services:
retries: 10
postgres:
image: postgres:18
image: mirror.gcr.io/postgres:18
restart: unless-stopped
environment:
POSTGRES_DB: forgebucket
@@ -34,5 +32,10 @@ services:
timeout: 5s
retries: 10
dbgate:
image: dbgate/dbgate
ports:
- "3000:3000"
volumes:
postgres_data:
+2
View File
@@ -40,6 +40,7 @@ const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage'))
const RepoSecurityPage = lazy(() => import('./pages/RepoSecurityPage'))
const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage'))
const WorkspacePage = lazy(() => import('./pages/WorkspacePage'))
const WorkspaceSettingsPage = lazy(() => import('./pages/WorkspaceSettingsPage'))
@@ -94,6 +95,7 @@ export default function App() {
<Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} />
<Route path="repos/:owner/:repo/timeline" element={<S><RepoTimelinePage /></S>} />
<Route path="repos/:owner/:repo/secrets" element={<S><RepoSecretsPage /></S>} />
<Route path="repos/:owner/:repo/security" element={<S><RepoSecurityPage /></S>} />
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
<Route path="starred" element={<S><StarredPage /></S>} />
+76
View File
@@ -0,0 +1,76 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api, ApiError } from '../client'
import type { SBOMReport } from '../../types/api'
const sbomReportSchema = z.object({
id: z.number(),
repoId: z.number(),
runId: z.number(),
sha: z.string(),
format: z.string(),
componentCount: z.number(),
generatedAt: z.string(),
})
/** SBOM metadata for a specific pipeline run. */
export function useRunSBOM(owner: string, repo: string, runId: number) {
return useQuery<SBOMReport | null>({
queryKey: ['repos', owner, repo, 'runs', runId, 'sbom'],
queryFn: async () => {
try {
return await api.get<SBOMReport>(
`/api/v1/repos/${owner}/${repo}/runs/${runId}/sbom`,
sbomReportSchema,
)
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null
throw err
}
},
enabled: Boolean(owner && repo && runId),
retry: false,
})
}
/** Latest SBOM metadata for a repo. */
export function useLatestSBOM(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'sbom'],
queryFn: () =>
api.get<SBOMReport>(
`/api/v1/repos/${owner}/${repo}/sbom`,
sbomReportSchema,
),
enabled: Boolean(owner && repo),
retry: false,
})
}
/** Download SBOM document URL for a specific run. */
export function getRunSBOMDocumentURL(owner: string, repo: string, runId: number): string {
return `/api/v1/repos/${owner}/${repo}/runs/${runId}/sbom/document`
}
/** Download latest SBOM document URL. */
export function getLatestSBOMDocumentURL(owner: string, repo: string): string {
return `/api/v1/repos/${owner}/${repo}/sbom/document`
}
/** Trigger on-demand SBOM generation. */
export function useGenerateSBOM(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ ref, runId }: { ref: string; runId?: number }) => {
let url = `/api/v1/repos/${owner}/${repo}/sbom/generate?ref=${encodeURIComponent(ref)}`
if (runId) url += `&runID=${runId}`
return api.post<SBOMReport>(url, sbomReportSchema, undefined)
},
onSuccess: (data, { runId }) => {
qc.setQueryData(['repos', owner, repo, 'sbom'], data)
if (runId) {
qc.setQueryData(['repos', owner, repo, 'runs', runId, 'sbom'], data)
}
},
})
}
+117
View File
@@ -0,0 +1,117 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { SecretLeak, VulnerabilityFinding } from '../../types/api'
// ── Zod schemas ───────────────────────────────────────────────────────────────
const secretLeakSchema = z.object({
id: z.number(),
repoId: z.number(),
commitSha: z.string(),
ref: z.string(),
patternName: z.string(),
description: z.string(),
severity: z.string(),
matchSample: z.string(),
dismissed: z.boolean(),
dismissedBy: z.string().optional(),
dismissedAt: z.string().nullable().optional(),
detectedAt: z.string(),
})
const vulnerabilityFindingSchema = z.object({
id: z.number(),
repoId: z.number(),
vulnId: z.string(),
purl: z.string(),
version: z.string(),
summary: z.string(),
details: z.string().optional(),
cvssScore: z.number(),
fixedVersion: z.string(),
dismissed: z.boolean(),
dismissedBy: z.string().optional(),
dismissedAt: z.string().nullable().optional(),
detectedAt: z.string(),
})
// ── Secret Leak queries ───────────────────────────────────────────────────────
/** Active secret leaks for a repo. */
export function useSecretLeaks(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'secrets', 'leaks'],
queryFn: () =>
api.get<SecretLeak[]>(
`/api/v1/repos/${owner}/${repo}/secrets/leaks`,
z.array(secretLeakSchema),
),
enabled: Boolean(owner && repo),
refetchInterval: 30_000,
})
}
/** Dismiss a secret leak. */
export function useDismissSecretLeak(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (leakId: number) =>
api.post(
`/api/v1/repos/${owner}/${repo}/secrets/leaks/${leakId}/dismiss`,
z.unknown(),
undefined,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'secrets', 'leaks'] })
},
})
}
// ── Vulnerability queries ─────────────────────────────────────────────────────
/** Active vulnerability findings for a repo. */
export function useVulnerabilities(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'vulnerabilities'],
queryFn: () =>
api.get<VulnerabilityFinding[]>(
`/api/v1/repos/${owner}/${repo}/vulnerabilities`,
z.array(vulnerabilityFindingSchema),
),
enabled: Boolean(owner && repo),
refetchInterval: 30_000,
})
}
/** Trigger a vulnerability scan. */
export function useScanVulnerabilities(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: () =>
api.post<VulnerabilityFinding[]>(
`/api/v1/repos/${owner}/${repo}/vulnerabilities/scan`,
z.array(vulnerabilityFindingSchema),
undefined,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'vulnerabilities'] })
},
})
}
/** Dismiss a vulnerability finding. */
export function useDismissVulnerability(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (findingId: number) =>
api.post(
`/api/v1/repos/${owner}/${repo}/vulnerabilities/${findingId}/dismiss`,
z.unknown(),
undefined,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'vulnerabilities'] })
},
})
}
+88 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines'
import { useRunSBOM, getRunSBOMDocumentURL, useGenerateSBOM } from '../api/queries/sbom'
import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils'
import type { PipelineJob, PipelineStep, RunStatus } from '../types/api'
@@ -27,6 +28,87 @@ function duration(start: string | null, end: string | null): string {
return `${m}m ${s % 60}s`
}
// ── SBOM section ──────────────────────────────────────────────────────────────
function SBOMSection({ owner, repo, runId, runStatus, triggerSha }: {
owner: string
repo: string
runId: number
runStatus: RunStatus
triggerSha: string
}) {
const { data: sbom, isLoading } = useRunSBOM(owner, repo, runId)
const generateSBOM = useGenerateSBOM(owner, repo)
if (isLoading) {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-3">
SBOM
</h2>
<Skeleton className="h-5 w-64 rounded" />
</section>
)
}
if (sbom) {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
SBOM CycloneDX
</h2>
<div className="flex items-center gap-3 text-xs text-[var(--c-muted)]">
<span>{sbom.componentCount} components</span>
<span className="font-mono">{sbom.sha.slice(0, 7)}</span>
<span>{new Date(sbom.generatedAt).toLocaleString()}</span>
</div>
</div>
<a
href={getRunSBOMDocumentURL(owner, repo, runId)}
download="bom.json"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors shrink-0"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download BOM
</a>
</div>
</section>
)
}
// No SBOM yet — show generate option for completed/failed runs
if (runStatus === 'succeeded' || runStatus === 'failed') {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
SBOM
</h2>
<p className="text-xs text-[var(--c-muted)]">No SBOM generated for this run.</p>
</div>
<button
onClick={() => generateSBOM.mutate({ ref: triggerSha, runId })}
disabled={generateSBOM.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50 shrink-0"
>
{generateSBOM.isPending ? 'Generating…' : 'Generate SBOM'}
</button>
</div>
{generateSBOM.isError && (
<p className="mt-2 text-xs text-[var(--c-danger)]">{(generateSBOM.error as Error).message}</p>
)}
</section>
)
}
return null
}
function shortRef(ref: string): string {
return ref.replace('refs/heads/', '').replace('refs/tags/', '')
}
@@ -172,7 +254,7 @@ function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] {
const job = nameToJob.get(name)
if (!job) return 0
let needs: string[] = []
try { needs = JSON.parse(job.needs || '[]') } catch { needs = [] }
try { needs = JSON.parse(job.needs || '[]') ?? [] } catch { needs = [] }
const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited))))
depth.set(name, d)
return d
@@ -357,6 +439,11 @@ export default function PipelineRunPage() {
</div>
) : null}
{/* SBOM section */}
{!isLoading && run && (
<SBOMSection owner={owner} repo={repo} runId={runIdNum} runStatus={run.status as RunStatus} triggerSha={run.triggerSha} />
)}
{/* DAG + log viewer */}
<div className="grid grid-cols-1 gap-4">
+1
View File
@@ -192,6 +192,7 @@ export default function RepoPage() {
<Link to={`/repos/${owner}/${repoName}/commits`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Commits</Link>
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Branches</Link>
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Issues</Link>
<Link to={`/repos/${owner}/${repoName}/security`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Security</Link>
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 ml-auto">Settings</Link>
</div>
+298
View File
@@ -0,0 +1,298 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useSecretLeaks, useDismissSecretLeak } from '../api/queries/security'
import { useVulnerabilities, useScanVulnerabilities, useDismissVulnerability } from '../api/queries/security'
import { useLatestSBOM, useGenerateSBOM, getLatestSBOMDocumentURL } from '../api/queries/sbom'
import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils'
const SEVERITY_COLORS: Record<string, string> = {
critical: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
high: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
medium: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
low: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
}
function cvssSeverity(score: number): { label: string; color: string } {
if (score >= 9) return { label: 'CRITICAL', color: SEVERITY_COLORS.critical }
if (score >= 7) return { label: 'HIGH', color: SEVERITY_COLORS.high }
if (score >= 4) return { label: 'MEDIUM', color: SEVERITY_COLORS.medium }
return { label: 'LOW', color: SEVERITY_COLORS.low }
}
// ── SBOM Section ──────────────────────────────────────────────────────────────
function SBOMSection({ owner, repo }: { owner: string; repo: string }) {
const { data: sbom, isLoading } = useLatestSBOM(owner, repo)
const generateSBOM = useGenerateSBOM(owner, repo)
const [ref, setRef] = useState('main')
if (isLoading) {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<Skeleton className="h-5 w-48 rounded" />
</section>
)
}
if (sbom) {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
SBOM {sbom.format}
</h2>
<div className="flex items-center gap-3 text-xs text-[var(--c-muted)]">
<span>{sbom.componentCount} components</span>
<span className="font-mono">{sbom.sha.slice(0, 7)}</span>
<span>{new Date(sbom.generatedAt).toLocaleString()}</span>
</div>
</div>
<a
href={getLatestSBOMDocumentURL(owner, repo)}
download="bom.json"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors shrink-0"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download BOM
</a>
</div>
</section>
)
}
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
SBOM
</h2>
<p className="text-xs text-[var(--c-muted)]">
No SBOM generated yet. Generate one to enable vulnerability scanning.
</p>
</div>
<div className="flex items-center gap-2">
<input
value={ref}
onChange={e => setRef(e.target.value)}
placeholder="branch or SHA"
className="w-36 px-2.5 py-1.5 text-xs border border-[var(--c-border)] rounded-lg bg-[var(--c-surface-muted)] text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)] font-mono"
/>
<button
onClick={() => generateSBOM.mutate({ ref })}
disabled={generateSBOM.isPending || !ref.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors disabled:opacity-50 shrink-0"
>
{generateSBOM.isPending ? 'Generating…' : 'Generate SBOM'}
</button>
</div>
</div>
{generateSBOM.isError && (
<p className="mt-2 text-xs text-[var(--c-danger)]">{(generateSBOM.error as Error).message}</p>
)}
</section>
)
}
// ── Secret Leaks Section ──────────────────────────────────────────────────────
function SecretLeaksSection({ owner, repo }: { owner: string; repo: string }) {
const { data: leaks, isLoading } = useSecretLeaks(owner, repo)
const dismissLeak = useDismissSecretLeak(owner, repo)
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<div className="flex items-center gap-2">
<svg width="14" height="14" fill="none" stroke="var(--c-danger)" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z" />
</svg>
<h2 className="text-sm font-semibold text-[var(--c-text)]">Secret Leaks</h2>
{!isLoading && leaks && leaks.length > 0 && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">
{leaks.length}
</span>
)}
</div>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <Skeleton key={i} className="h-12 rounded" />)}
</div>
) : !leaks?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
No secret leaks detected.
</div>
) : (
<div className="divide-y divide-[var(--c-border)]">
{leaks.map(leak => (
<div key={leak.id} className="flex items-start gap-3 px-4 py-3">
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full mt-0.5 shrink-0',
SEVERITY_COLORS[leak.severity] ?? SEVERITY_COLORS.medium,
)}>
{leak.severity}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--c-text)]">{leak.patternName}</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">{leak.description}</p>
<div className="flex items-center gap-2.5 mt-1 text-[10px] text-[var(--c-subtle)] font-mono">
<span>{leak.commitSha}</span>
<span>{leak.ref.replace('refs/heads/', '')}</span>
<span>{new Date(leak.detectedAt).toLocaleDateString()}</span>
</div>
{leak.matchSample && (
<code className="inline-block mt-1 px-2 py-0.5 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded text-[10px] font-mono text-[var(--c-muted)]">
{leak.matchSample}
</code>
)}
</div>
<button
onClick={() => dismissLeak.mutate(leak.id)}
disabled={dismissLeak.isPending}
className="shrink-0 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
>
Dismiss
</button>
</div>
))}
</div>
)}
</section>
)
}
// ── Vulnerabilities Section ───────────────────────────────────────────────────
function VulnerabilitiesSection({ owner, repo }: { owner: string; repo: string }) {
const { data: findings, isLoading } = useVulnerabilities(owner, repo)
const scanMut = useScanVulnerabilities(owner, repo)
const dismissVuln = useDismissVulnerability(owner, repo)
const { data: sbom } = useLatestSBOM(owner, repo)
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<div className="flex items-center gap-2">
<svg width="14" height="14" fill="none" stroke="var(--c-danger)" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126Z M12 15.75h.007v.008H12v-.008Z" />
</svg>
<h2 className="text-sm font-semibold text-[var(--c-text)]">Vulnerabilities</h2>
{!isLoading && findings && findings.length > 0 && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">
{findings.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
{!sbom && (
<span className="text-[10px] text-[var(--c-muted)]">No SBOM available</span>
)}
<button
onClick={() => scanMut.mutate()}
disabled={scanMut.isPending || !sbom}
className="flex items-center gap-1 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
>
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
{scanMut.isPending ? 'Scanning…' : 'Scan now'}
</button>
</div>
</div>
{scanMut.isError && (
<div className="px-4 py-2 text-xs text-[var(--c-danger)] bg-[var(--c-danger-tint)]/30">
Scan failed: {(scanMut.error as Error).message}
</div>
)}
{scanMut.isSuccess && findings && findings.length === 0 && (
<div className="px-4 py-2 text-xs text-[var(--c-success)] bg-[#E3FCEF] dark:bg-green-900/20">
Scan complete no vulnerabilities found.
</div>
)}
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <Skeleton key={i} className="h-16 rounded" />)}
</div>
) : !findings?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
{sbom
? 'No vulnerability findings. Run a scan to check dependencies.'
: 'No SBOM available. Push a commit with a supported manifest (package.json, go.mod, etc.) or trigger a pipeline run to generate one.'}
</div>
) : (
<div className="divide-y divide-[var(--c-border)]">
{findings.map(f => {
const sev = cvssSeverity(f.cvssScore)
return (
<div key={f.id} className="flex items-start gap-3 px-4 py-3">
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full mt-0.5 shrink-0',
sev.color,
)}>
{sev.label}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--c-text)]">{f.vulnId}</span>
<span className="text-[10px] text-[var(--c-subtle)] font-mono">CVSS {f.cvssScore.toFixed(1)}</span>
</div>
<p className="text-xs text-[var(--c-text)] mt-0.5">{f.summary}</p>
<div className="flex items-center gap-2.5 mt-1 text-[10px] text-[var(--c-subtle)] font-mono">
<span>{f.purl}</span>
<span>v{f.version}</span>
{f.fixedVersion && <span> fix: {f.fixedVersion}</span>}
</div>
</div>
<button
onClick={() => dismissVuln.mutate(f.id)}
disabled={dismissVuln.isPending}
className="shrink-0 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
>
Dismiss
</button>
</div>
)
})}
</div>
)}
</section>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function RepoSecurityPage() {
const { owner = '', repo = '' } = useParams()
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-5 space-y-6">
{/* Breadcrumb */}
<div>
<div className="flex items-center gap-1.5 text-xs text-[var(--c-muted)] mb-1">
<Link to="/repos" className="hover:text-[var(--c-brand)]">Repositories</Link>
<span>/</span>
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{owner}/{repo}</Link>
<span>/</span>
<span className="text-[var(--c-text)]">Security</span>
</div>
<h1 className="text-lg font-semibold text-[var(--c-text)]">Security</h1>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Secret leak detection and dependency vulnerability scanning.
</p>
</div>
<SBOMSection owner={owner} repo={repo} />
<SecretLeaksSection owner={owner} repo={repo} />
<VulnerabilitiesSection owner={owner} repo={repo} />
</div>
)
}
+47
View File
@@ -342,3 +342,50 @@ export interface ApiError {
export interface HealthResponse {
status: 'ok'
}
// ── SBOM (Phase 4.2) ───────────────────────────────────────────────────────────
export interface SBOMReport {
id: number
repoId: number
runId: number
sha: string
format: string
componentCount: number
generatedAt: string
}
// ── Secret Scanning (Phase 4.4) ────────────────────────────────────────────────
export interface SecretLeak {
id: number
repoId: number
commitSha: string
ref: string
patternName: string
description: string
severity: string
matchSample: string
dismissed: boolean
dismissedBy?: string
dismissedAt?: string | null
detectedAt: string
}
// ── Vulnerability Scanning (Phase 4.5) ─────────────────────────────────────────
export interface VulnerabilityFinding {
id: number
repoId: number
vulnId: string
purl: string
version: string
summary: string
details?: string
cvssScore: number
fixedVersion: string
dismissed: boolean
dismissedBy?: string
dismissedAt?: string | null
detectedAt: string
}
+11 -2
View File
@@ -8,22 +8,31 @@ require (
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.12.3
github.com/nats-io/nats.go v1.52.0
github.com/prometheus/client_golang v1.23.2
golang.org/x/crypto v0.50.0
gopkg.in/yaml.v3 v3.0.1
nhooyr.io/websocket v1.8.17
xorm.io/xorm v1.3.11
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/nats-io/nats.go v1.52.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
google.golang.org/protobuf v1.36.8 // indirect
xorm.io/builder v0.3.13 // indirect
)
+34
View File
@@ -2,6 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -20,6 +24,8 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -36,12 +42,20 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
@@ -57,14 +71,28 @@ github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
@@ -78,12 +106,18 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+104 -19
View File
@@ -1,29 +1,33 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/signing"
"github.com/forgeo/forgebucket/internal/models"
)
type ArtifactHandler struct {
db *xorm.Engine
artifactRoot string
keys *signing.KeyStore
}
func NewArtifactHandler(db *xorm.Engine, artifactRoot string) *ArtifactHandler {
return &ArtifactHandler{db: db, artifactRoot: artifactRoot}
func NewArtifactHandler(db *xorm.Engine, artifactRoot string, keys *signing.KeyStore) *ArtifactHandler {
return &ArtifactHandler{db: db, artifactRoot: artifactRoot, keys: keys}
}
// ListArtifacts returns all artifacts for a pipeline run.
// List returns all artifacts for a pipeline run.
func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
repoID, runID, ok := h.resolveRunIDs(w, r)
if !ok {
@@ -40,8 +44,8 @@ func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
jsonOK(w, artifacts)
}
// Upload accepts a multipart file upload and stores it as an artifact.
// Callers must provide a valid Bearer access token with write scope (runner auth).
// Upload accepts a multipart file upload, stores it as an artifact, and
// immediately signs it using the server's signing key.
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
repoID, runID, ok := h.resolveRunIDs(w, r)
if !ok {
@@ -87,8 +91,13 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
}
defer dst.Close()
size, err := io.Copy(dst, file)
// Read into memory so we can both write to disk and sign.
content, err := io.ReadAll(file)
if err != nil {
jsonError(w, "could not read upload", http.StatusInternalServerError)
return
}
if _, err := dst.Write(content); err != nil {
jsonError(w, "could not write file", http.StatusInternalServerError)
return
}
@@ -106,7 +115,7 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
RepoID: repoID,
Name: name,
StoragePath: relPath,
Size: size,
Size: int64(len(content)),
ContentType: ct,
}
if _, err := h.db.Insert(artifact); err != nil {
@@ -114,10 +123,38 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
return
}
// Sign the artifact and persist the bundle.
go h.signArtifact(artifact.ID, name, content)
w.WriteHeader(http.StatusCreated)
jsonOK(w, artifact)
}
// signArtifact is called in a goroutine after a successful upload.
func (h *ArtifactHandler) signArtifact(artifactID int64, name string, content []byte) {
bundle, err := h.keys.Sign(artifactID, name, content)
if err != nil {
fmt.Printf("signing: failed to sign artifact %d: %v\n", artifactID, err)
return
}
bundleJSON, err := json.Marshal(bundle)
if err != nil {
fmt.Printf("signing: failed to marshal bundle for artifact %d: %v\n", artifactID, err)
return
}
sig := &models.ArtifactSignature{
ArtifactID: artifactID,
KeyID: bundle.KeyID,
Algorithm: "ecdsa-p256-sha256",
Digest: bundle.Payload.Digest,
BundleJSON: string(bundleJSON),
SignedAt: time.Now().UTC(),
}
if _, err := h.db.Insert(sig); err != nil {
fmt.Printf("signing: failed to store signature for artifact %d: %v\n", artifactID, err)
}
}
// Download streams the artifact file to the client.
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
@@ -132,7 +169,6 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
}
fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath))
// Ensure the resolved path stays within artifactRoot (traversal guard).
if !isUnder(h.artifactRoot, fullPath) {
jsonError(w, "forbidden", http.StatusForbidden)
return
@@ -155,17 +191,66 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
io.Copy(w, f) //nolint:errcheck
}
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, 0, false
// GetSignature returns the full signature bundle JSON for an artifact.
// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/signature
func (h *ArtifactHandler) GetSignature(w http.ResponseWriter, r *http.Request) {
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
if err != nil {
jsonError(w, "invalid artifact ID", http.StatusBadRequest)
return
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
var sig models.ArtifactSignature
found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if !found {
jsonError(w, "signature not found — artifact may still be pending signing", http.StatusNotFound)
return
}
// Return the raw bundle JSON so clients can verify independently.
w.Header().Set("Content-Type", "application/vnd.forgebucket.signature.bundle+json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(sig.BundleJSON)) //nolint:errcheck
}
// VerifySignature verifies the stored signature bundle for an artifact.
// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/verify
func (h *ArtifactHandler) VerifySignature(w http.ResponseWriter, r *http.Request) {
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
if err != nil {
jsonError(w, "invalid artifact ID", http.StatusBadRequest)
return
}
var sig models.ArtifactSignature
found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if !found {
jsonError(w, "signature not found", http.StatusNotFound)
return
}
result, err := h.keys.Verify([]byte(sig.BundleJSON))
if err != nil {
jsonError(w, fmt.Sprintf("verification error: %v", err), http.StatusUnprocessableEntity)
return
}
jsonOK(w, result)
}
// ─── helpers ─────────────────────────────────────────────────────────────────
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) {
rID, ok := resolveRepoID(h.db, w, r)
if !ok {
return 0, 0, false
}
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
@@ -173,7 +258,7 @@ func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request)
jsonError(w, "invalid run ID", http.StatusBadRequest)
return 0, 0, false
}
return repo.ID, runID, true
return rID, runID, true
}
func isUnder(root, path string) bool {
+3 -26
View File
@@ -323,19 +323,7 @@ func (h *EnvironmentHandler) UpdateDeploymentStatus(w http.ResponseWriter, r *ht
// ── Helpers ───────────────────────────────────────────────────────────────────
func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
return resolveRepoID(h.db, w, r)
}
func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
@@ -352,26 +340,15 @@ func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request)
return &env, true
}
type deployEventPayload struct {
DeploymentID int64 `json:"deploymentId"`
EnvID int64 `json:"envId"`
EnvName string `json:"envName"`
RepoID int64 `json:"repoId"`
SHA string `json:"sha"`
Ref string `json:"ref"`
Status models.DeployStatus `json:"status"`
TriggeredBy string `json:"triggeredBy"`
}
func (h *EnvironmentHandler) publishDeployEvent(subject string, env *models.Environment, d *models.Deployment) {
h.bus.Publish(subject, deployEventPayload{ //nolint:errcheck
h.bus.Publish(subject, events.DeploymentEvent{ //nolint:errcheck
DeploymentID: d.ID,
EnvID: env.ID,
EnvName: env.Name,
RepoID: d.RepoID,
SHA: d.SHA,
Ref: d.Ref,
Status: d.Status,
Status: string(d.Status),
TriggeredBy: d.TriggeredBy,
})
}
+255
View File
@@ -0,0 +1,255 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/domain/federation"
"github.com/forgeo/forgebucket/internal/models"
)
const activityJSONType = "application/activity+json"
type FederationHandler struct {
db *xorm.Engine
cfg *config.Config
}
func NewFederationHandler(db *xorm.Engine, cfg *config.Config) *FederationHandler {
return &FederationHandler{db: db, cfg: cfg}
}
// WebFinger handles GET /.well-known/webfinger?resource=acct:user@domain
func (h *FederationHandler) WebFinger(w http.ResponseWriter, r *http.Request) {
resource := r.URL.Query().Get("resource")
if !strings.HasPrefix(resource, "acct:") {
http.Error(w, "resource must use acct: scheme", http.StatusBadRequest)
return
}
// acct:username@domain — extract username
acct := strings.TrimPrefix(resource, "acct:")
username := strings.SplitN(acct, "@", 2)[0]
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actorURL := federation.APID(h.cfg.InstanceURL, username)
resp := map[string]any{
"subject": resource,
"links": []map[string]any{
{
"rel": "self",
"type": activityJSONType,
"href": actorURL,
},
},
}
w.Header().Set("Content-Type", "application/jrd+json")
json.NewEncoder(w).Encode(resp) //nolint:errcheck
}
// Actor handles GET /users/{username} — returns the JSON-LD actor document.
func (h *FederationHandler) Actor(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "could not get actor", http.StatusInternalServerError)
return
}
doc := federation.ActorJSON(actor, username, username)
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(doc) //nolint:errcheck
}
// Inbox handles POST /users/{username}/inbox — receive an ActivityPub activity.
func (h *FederationHandler) Inbox(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB max
if err != nil {
http.Error(w, "could not read body", http.StatusBadRequest)
return
}
// Verify HTTP signature. In debug mode, skip verification so local testing works.
if !h.cfg.Debug {
if err := federation.Verify(r, h.db, h.cfg.InstanceURL); err != nil {
http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized)
return
}
}
if err := federation.Receive(h.db, actor, body); err != nil {
http.Error(w, "could not process activity: "+err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusAccepted)
}
// OutboxGet handles GET /users/{username}/outbox — serve the activity collection.
func (h *FederationHandler) OutboxGet(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
coll := federation.Collection(h.db, actor.APID, actor.OutboxURL, page)
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(coll) //nolint:errcheck
}
// Followers handles GET /users/{username}/followers
func (h *FederationHandler) Followers(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
coll := federation.StubCollection(actor.APID + "/followers")
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(coll) //nolint:errcheck
}
// RepoActor handles GET /repos/{owner}/{repo}/actor — returns the ForgeFed
// Repository actor document for cross-instance pull requests.
func (h *FederationHandler) RepoActor(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var repo models.Repository
if found, _ := h.db.Where("name = ?", repoName).
Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner).
Get(&repo); !found {
http.NotFound(w, r)
return
}
doc := federation.RepoActorJSON(owner, repoName, repo.Description, h.cfg.InstanceURL)
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(doc) //nolint:errcheck
}
// RepoInbox handles POST /repos/{owner}/{repo}/inbox — receive ForgeFed
// activities for a repository (e.g. Create(PullRequest)).
func (h *FederationHandler) RepoInbox(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var repo models.Repository
if found, _ := h.db.Where("name = ?", repoName).
Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner).
Get(&repo); !found {
http.NotFound(w, r)
return
}
_ = repo
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "could not read body", http.StatusBadRequest)
return
}
// Determine the local repo actor APID.
localActorAPID := federation.RepoAPID(h.cfg.InstanceURL, owner, repoName)
// For repository inbox, we need a local actor for the repo owner.
var ownerUser models.User
if found, _ := h.db.Where("username = ?", owner).Get(&ownerUser); !found {
http.Error(w, "owner not found", http.StatusInternalServerError)
return
}
if !h.cfg.Debug {
if err := federation.Verify(r, h.db, h.cfg.InstanceURL); err != nil {
http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized)
return
}
}
// Persist the activity.
entry := &models.FederationActivity{
ActorAPID: localActorAPID,
Type: "Create",
ObjectJSON: string(body),
Direction: "inbound",
RemoteActor: localActorAPID,
Published: time.Now().UTC(),
}
h.db.Insert(entry) //nolint:errcheck
// Handle Create(PullRequest).
if err := federation.HandleCreatePullRequest(h.db, body, h.cfg.InstanceURL); err != nil {
log.Printf("federation: repo inbox handle: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}
func (h *FederationHandler) Following(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
coll := federation.StubCollection(actor.APID + "/following")
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(coll) //nolint:errcheck
}
+252
View File
@@ -0,0 +1,252 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
type GitOpsHandler struct {
db *xorm.Engine
bus events.EventBus
}
func NewGitOpsHandler(db *xorm.Engine, bus events.EventBus) *GitOpsHandler {
return &GitOpsHandler{db: db, bus: bus}
}
// GetConfig returns the GitOpsConfig for an environment, or 404 if not configured.
func (h *GitOpsHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
var cfg models.GitOpsConfig
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
return
}
jsonOK(w, cfg)
}
// UpsertConfig creates or replaces the GitOpsConfig for an environment.
func (h *GitOpsHandler) UpsertConfig(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
var body struct {
Branch string `json:"branch"`
AutoSync bool `json:"autoSync"`
SyncInterval int `json:"syncInterval"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Branch == "" {
jsonError(w, "branch is required", http.StatusBadRequest)
return
}
var cfg models.GitOpsConfig
exists, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg)
cfg.EnvID = env.ID
cfg.RepoID = env.RepoID
cfg.Branch = body.Branch
cfg.AutoSync = body.AutoSync
cfg.SyncInterval = body.SyncInterval
if cfg.SyncStatus == "" {
cfg.SyncStatus = "unknown"
}
var err error
if exists {
_, err = h.db.ID(cfg.ID).Cols("branch", "auto_sync", "sync_interval").Update(&cfg)
} else {
_, err = h.db.Insert(&cfg)
}
if err != nil {
jsonError(w, "could not save gitops config", http.StatusInternalServerError)
return
}
jsonOK(w, cfg)
}
// DeleteConfig removes the GitOpsConfig for an environment without deleting deployments.
func (h *GitOpsHandler) DeleteConfig(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
if _, err := h.db.Where("env_id = ?", env.ID).Delete(&models.GitOpsConfig{}); err != nil {
jsonError(w, "could not delete gitops config", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// TriggerSync manually initiates a reconciliation for the environment.
func (h *GitOpsHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
var cfg models.GitOpsConfig
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
return
}
if cfg.DesiredSHA == "" {
jsonError(w, "no desired SHA known yet — push to the configured branch first", http.StatusConflict)
return
}
if cfg.SyncStatus == "syncing" {
jsonError(w, "a sync is already in progress", http.StatusConflict)
return
}
now := time.Now().UTC()
deploy := &models.Deployment{
EnvID: env.ID,
RepoID: env.RepoID,
SHA: cfg.DesiredSHA,
Ref: "refs/heads/" + cfg.Branch,
Status: models.DeployStatusPending,
TriggeredBy: "gitops-manual",
Description: "Manual GitOps sync",
StartedAt: &now,
}
if _, err := h.db.Insert(deploy); err != nil {
jsonError(w, "could not create deployment", http.StatusInternalServerError)
return
}
cfg.SyncStatus = "syncing"
h.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
h.bus.Publish(events.SubjectDeploymentStarted, events.DeploymentEvent{ //nolint:errcheck
DeploymentID: deploy.ID,
EnvID: env.ID,
EnvName: env.Name,
RepoID: deploy.RepoID,
SHA: deploy.SHA,
Ref: deploy.Ref,
Status: string(deploy.Status),
TriggeredBy: deploy.TriggeredBy,
})
w.WriteHeader(http.StatusCreated)
jsonOK(w, deploy)
}
// GetDriftStatus returns the current sync status and SHA comparison for an environment.
func (h *GitOpsHandler) GetDriftStatus(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
var cfg models.GitOpsConfig
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
return
}
type driftStatus struct {
SyncStatus string `json:"syncStatus"`
DesiredSHA string `json:"desiredSha"`
ActualSHA string `json:"actualSha"`
Branch string `json:"branch"`
IsDrifted bool `json:"isDrifted"`
}
jsonOK(w, driftStatus{
SyncStatus: cfg.SyncStatus,
DesiredSHA: cfg.DesiredSHA,
ActualSHA: cfg.ActualSHA,
Branch: cfg.Branch,
IsDrifted: cfg.DesiredSHA != cfg.ActualSHA && cfg.DesiredSHA != "",
})
}
// ListDriftEvents returns the drift history for an environment, newest first.
func (h *GitOpsHandler) ListDriftEvents(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
limit := 50
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
limit = l
}
var drifts []models.GitOpsDriftEvent
if err := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(limit).Find(&drifts); err != nil {
jsonError(w, "could not list drift events", http.StatusInternalServerError)
return
}
if drifts == nil {
drifts = []models.GitOpsDriftEvent{}
}
jsonOK(w, drifts)
}
// AcknowledgeDrift marks a drift event as acknowledged without triggering a sync.
func (h *GitOpsHandler) AcknowledgeDrift(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
driftID, err := strconv.ParseInt(chi.URLParam(r, "driftID"), 10, 64)
if err != nil {
jsonError(w, "invalid drift event ID", http.StatusBadRequest)
return
}
var drift models.GitOpsDriftEvent
if found, _ := h.db.Where("id = ? AND env_id = ?", driftID, env.ID).Get(&drift); !found {
jsonError(w, "drift event not found", http.StatusNotFound)
return
}
if drift.ResolvedAt != nil {
jsonError(w, "drift event is already resolved", http.StatusConflict)
return
}
now := time.Now().UTC()
drift.SyncStatus = "acknowledged"
drift.ResolvedAt = &now
if _, err := h.db.ID(drift.ID).Cols("sync_status", "resolved_at").Update(&drift); err != nil {
jsonError(w, "could not acknowledge drift", http.StatusInternalServerError)
return
}
jsonOK(w, drift)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func (h *GitOpsHandler) resolveGitOpsEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return nil, false
}
envName := chi.URLParam(r, "envName")
var env models.Environment
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, envName).Get(&env); !found {
jsonError(w, "environment not found", http.StatusNotFound)
return nil, false
}
return &env, true
}
+1 -14
View File
@@ -132,20 +132,7 @@ func (h *IssueHandler) Reopen(w http.ResponseWriter, r *http.Request) {
}
func (h *IssueHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
return resolveRepoID(h.db, w, r)
}
func (h *IssueHandler) lookupIssue(w http.ResponseWriter, r *http.Request) (*models.Issue, bool) {
+6 -12
View File
@@ -48,19 +48,13 @@ type deployKeyResponse struct {
}
func (h *DeployKeyHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return nil, nil, false
}
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
return &repo, &owner, true
h.db.ID(repo.OwnerID).Get(&owner)
return repo, &owner, true
}
func (h *DeployKeyHandler) canManage(repo *models.Repository, callerID int64) bool {
+1 -26
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
@@ -16,31 +15,7 @@ type LFSHandler struct{ db *xorm.Engine }
func NewLFSHandler(db *xorm.Engine) *LFSHandler { return &LFSHandler{db: db} }
func (h *LFSHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
return resolveRepo(h.db, w, r)
}
func (h *LFSHandler) canManage(repo *models.Repository, callerID int64) bool {
+7 -14
View File
@@ -28,22 +28,15 @@ type memberResponse struct {
AddedAt string `json:"addedAt"`
}
// lookupRepoForMembers resolves the repo from URL params and returns the owner User.
// lookupRepoAndOwner resolves {owner}/{repo} and returns the repo + its creator user.
func (h *MemberHandler) lookupRepoAndOwner(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return nil, nil, false
}
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
return &repo, &owner, true
h.db.ID(repo.OwnerID).Get(&owner)
return repo, &owner, true
}
// callerCanManage returns true if callerID is the repo owner or has admin permission.
+126
View File
@@ -0,0 +1,126 @@
package handlers
import (
"net/http"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
"github.com/forgeo/forgebucket/internal/observability"
)
// ── /health ───────────────────────────────────────────────────────────────────
type HealthHandler struct {
db *xorm.Engine
bus events.EventBus
}
func NewHealthHandler(db *xorm.Engine, bus events.EventBus) *HealthHandler {
return &HealthHandler{db: db, bus: bus}
}
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
status := observability.Check(h.db, h.bus)
if status.Status != "healthy" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
jsonOK(w, status)
return
}
jsonOK(w, status)
}
// ── /api/v1/repos/{owner}/{repo}/health ──────────────────────────────────────
type RepoHealthHandler struct{ db *xorm.Engine }
func NewRepoHealthHandler(db *xorm.Engine) *RepoHealthHandler {
return &RepoHealthHandler{db: db}
}
type latestDeployment struct {
EnvName string `json:"envName"`
Status string `json:"status"`
SHA string `json:"sha"`
FinishedAt *time.Time `json:"finishedAt"`
}
type repoHealthResponse struct {
CIPassRate7d float64 `json:"ciPassRate7d"`
TotalRuns7d int `json:"totalRuns7d"`
LatestRun *models.PipelineRun `json:"latestRun"`
LatestDeployments []latestDeployment `json:"latestDeployments"`
OpenDriftCount int `json:"openDriftCount"`
OpenPRCount int `json:"openPRCount"`
}
// Get returns an operational health summary for a repository.
// This feeds the repo page header: CI pass rate, latest deploy per env, drift count.
func (h *RepoHealthHandler) Get(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
since7d := time.Now().UTC().Add(-7 * 24 * time.Hour)
// CI pass rate over last 7 days.
var runs []models.PipelineRun
h.db.Where("repo_id = ? AND created_at >= ?", repoID, since7d).Find(&runs)
total := len(runs)
succeeded := 0
for _, run := range runs {
if run.Status == "succeeded" {
succeeded++
}
}
var passRate float64
if total > 0 {
passRate = float64(succeeded) / float64(total)
}
// Latest run overall.
var latestRun models.PipelineRun
var hasLatest bool
hasLatest, _ = h.db.Where("repo_id = ?", repoID).Desc("id").Limit(1).Get(&latestRun)
// Latest deployment per environment.
var envs []models.Environment
h.db.Where("repo_id = ?", repoID).Find(&envs)
deploys := make([]latestDeployment, 0, len(envs))
for _, env := range envs {
var d models.Deployment
if found, _ := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(1).Get(&d); found {
deploys = append(deploys, latestDeployment{
EnvName: env.Name,
Status: string(d.Status),
SHA: d.SHA,
FinishedAt: d.FinishedAt,
})
}
}
// Open drift count (GitOpsConfigs where sync_status = 'drifted').
driftCount, _ := h.db.Where("repo_id = ? AND sync_status = 'drifted'", repoID).
Count(&models.GitOpsConfig{})
// Open PR count.
prCount, _ := h.db.Where("repo_id = ? AND status = 'open'", repoID).
Count(&models.PullRequest{})
resp := repoHealthResponse{
CIPassRate7d: passRate,
TotalRuns7d: total,
LatestDeployments: deploys,
OpenDriftCount: int(driftCount),
OpenPRCount: int(prCount),
}
if hasLatest {
resp.LatestRun = &latestRun
}
jsonOK(w, resp)
}
+529
View File
@@ -0,0 +1,529 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/oci"
"github.com/forgeo/forgebucket/internal/models"
)
// OCIRegistryHandler serves the OCI Distribution API at /v2/.
type OCIRegistryHandler struct {
db *xorm.Engine
reg *oci.Registry
}
func NewOCIRegistryHandler(db *xorm.Engine, reg *oci.Registry) *OCIRegistryHandler {
return &OCIRegistryHandler{db: db, reg: reg}
}
// ServeOCI is the catch-all handler mounted at /v2/.
func (h *OCIRegistryHandler) ServeOCI(w http.ResponseWriter, r *http.Request) {
// GET /v2/ — API version check.
if r.Method == http.MethodGet && (r.URL.Path == "/v2/" || r.URL.Path == "/v2") {
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
return
}
name, kind, ref := oci.ParseOCIPath(r.URL.Path)
if name == "" {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "invalid OCI path")
return
}
// Resolve ForgeBucket repository from image name (expected format: owner/repo).
owner, repoName, found := strings.Cut(name, "/")
if !found {
h.ociError(w, http.StatusBadRequest, oci.ErrNameInvalid, "image name must be owner/repo-name")
return
}
var repo models.Repository
if ok, _ := h.db.Where("name = ?", repoName).Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner).Get(&repo); !ok {
h.ociError(w, http.StatusNotFound, oci.ErrNameUnknown, "repository not found")
return
}
// Authenticate.
authedUser := h.basicAuthOCI(r)
needsAuth := repo.IsPrivate || r.Method != http.MethodGet
if needsAuth && authedUser == "" {
w.Header().Set("Www-Authenticate", `Basic realm="ForgeBucket OCI Registry"`)
h.ociError(w, http.StatusUnauthorized, oci.ErrUnauthorized, "authentication required")
return
}
if authedUser != "" {
hasWrite := HasPermission(h.db, &repo, authedUser, "write")
hasRead := HasPermission(h.db, &repo, authedUser, "read")
if !hasRead {
h.ociError(w, http.StatusForbidden, oci.ErrDenied, "access denied")
return
}
// Mutations require write access.
isMut := r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete
if isMut && !hasWrite {
h.ociError(w, http.StatusForbidden, oci.ErrDenied, "write access required")
return
}
}
// Resolve or create OCIRepository row.
ociRepo, err := h.getOrCreateOCIRepo(repo.ID, name)
if err != nil {
h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUnknown, "internal error")
return
}
// Route to handler by (method, kind).
switch r.Method {
case http.MethodGet:
switch kind {
case "tags":
h.listTags(w, r, ociRepo)
case "manifest":
h.getManifest(w, r, ociRepo, ref)
case "blob":
h.getBlob(w, r, repo, ociRepo, ref)
case "upload":
h.getUploadStatus(w, r, ociRepo, ref)
default:
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
case http.MethodHead:
if kind == "blob" {
h.headBlob(w, r, ref)
} else {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
case http.MethodPost:
if kind == "upload" && ref == "" {
h.startUpload(w, r, ociRepo)
} else {
h.ociError(w, http.StatusMethodNotAllowed, oci.ErrUnsupported, "method not allowed")
}
case http.MethodPatch:
if kind == "upload" && ref != "" {
h.patchUpload(w, r, ociRepo, ref)
} else {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
case http.MethodPut:
if kind == "upload" && ref != "" {
h.finishUpload(w, r, ociRepo, ref)
} else if kind == "manifest" {
h.pushManifest(w, r, ociRepo, ref)
} else {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
case http.MethodDelete:
if kind == "manifest" {
h.deleteManifest(w, r, ociRepo, ref)
} else if kind == "blob" {
h.deleteBlob(w, r, ociRepo, ref)
} else if kind == "upload" && ref != "" {
h.cancelUpload(w, r, ref)
} else {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
default:
h.ociError(w, http.StatusMethodNotAllowed, oci.ErrUnsupported, "method not allowed")
}
}
// ─── GET /v2/{name}/tags/list ────────────────────────────────────────────────
func (h *OCIRegistryHandler) listTags(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository) {
var tags []models.OCITag
h.db.Where("oci_repo_id = ?", ociRepo.ID).Find(&tags)
names := make([]string, 0, len(tags))
for _, t := range tags {
names = append(names, t.Name)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"name": ociRepo.Name,
"tags": names,
})
}
// ─── GET /v2/{name}/manifests/{ref} ──────────────────────────────────────────
func (h *OCIRegistryHandler) getManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
digest := ref
if !oci.IsDigestRef(ref) {
// ref is a tag — resolve to digest.
var tag models.OCITag
if found, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(&tag); !found {
h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, fmt.Sprintf("tag %q not found", ref))
return
}
digest = tag.Digest
}
var manifest models.OCIManifest
if found, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, digest).Get(&manifest); !found {
h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "manifest not found")
return
}
w.Header().Set("Content-Type", manifest.MediaType)
w.Header().Set("Docker-Content-Digest", manifest.Digest)
w.Header().Set("Content-Length", fmt.Sprintf("%d", manifest.Size))
w.WriteHeader(http.StatusOK)
w.Write([]byte(manifest.Content)) //nolint:errcheck
}
// ─── PUT /v2/{name}/manifests/{ref} ──────────────────────────────────────────
func (h *OCIRegistryHandler) pushManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
body, err := io.ReadAll(r.Body)
if err != nil {
h.ociError(w, http.StatusBadRequest, oci.ErrManifestInvalid, "cannot read body")
return
}
if len(body) == 0 {
h.ociError(w, http.StatusBadRequest, oci.ErrManifestInvalid, "empty manifest body")
return
}
mediaType := r.Header.Get("Content-Type")
if mediaType == "" {
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
}
manifestDigest, manifestSize := oci.ManifestDescriptor(body)
// Persist manifest.
m := &models.OCIManifest{
OCIRepoID: ociRepo.ID,
Digest: manifestDigest,
MediaType: mediaType,
Size: manifestSize,
Content: string(body),
}
if _, err := h.db.Insert(m); err != nil {
// Duplicate digest is fine — manifests are immutable.
if has, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, manifestDigest).Get(&models.OCIManifest{}); !has {
h.ociError(w, http.StatusInternalServerError, oci.ErrManifestInvalid, "store manifest failed")
return
}
}
// If ref is not a digest, treat it as a tag.
if !oci.IsDigestRef(ref) {
tag := &models.OCITag{
OCIRepoID: ociRepo.ID,
Name: ref,
Digest: manifestDigest,
}
existing := &models.OCITag{}
if has, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(existing); has {
existing.Digest = manifestDigest
existing.UpdatedAt = time.Now()
h.db.ID(existing.ID).Cols("digest", "updated_at").Update(existing)
} else {
h.db.Insert(tag)
}
}
// Track blobs referenced by this manifest so GC can work.
h.trackBlobRefs(ociRepo, body)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", ociRepo.Name, manifestDigest))
w.Header().Set("Content-Type", mediaType)
w.Header().Set("Docker-Content-Digest", manifestDigest)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"digest": manifestDigest})
}
// trackBlobRefs parses the manifest and ensures referenced blob digests exist as OCIBlob rows.
func (h *OCIRegistryHandler) trackBlobRefs(ociRepo *models.OCIRepository, body []byte) {
var manifest struct {
Layers []struct {
Digest string `json:"digest"`
} `json:"layers"`
Config struct {
Digest string `json:"digest"`
} `json:"config"`
}
if err := json.Unmarshal(body, &manifest); err != nil {
return
}
digests := []string{}
if manifest.Config.Digest != "" {
digests = append(digests, manifest.Config.Digest)
}
for _, layer := range manifest.Layers {
if layer.Digest != "" {
digests = append(digests, layer.Digest)
}
}
for _, d := range digests {
if h.reg.BlobExists(d) {
h.db.Insert(&models.OCIBlob{Digest: d, Size: h.reg.BlobSize(d)}) //nolint:errcheck,nestif
}
}
}
// ─── DELETE /v2/{name}/manifests/{ref} ───────────────────────────────────────
func (h *OCIRegistryHandler) deleteManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
digest := ref
if !oci.IsDigestRef(ref) {
var tag models.OCITag
if found, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(&tag); !found {
h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "tag not found")
return
}
digest = tag.Digest
// Delete the tag.
h.db.ID(tag.ID).Delete(&models.OCITag{})
}
affected, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, digest).Delete(&models.OCIManifest{})
if affected == 0 {
h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "manifest not found")
return
}
w.WriteHeader(http.StatusAccepted)
}
// ─── HEAD /v2/{name}/blobs/{digest} ──────────────────────────────────────────
func (h *OCIRegistryHandler) headBlob(w http.ResponseWriter, r *http.Request, digest string) {
if !h.reg.BlobExists(digest) {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
return
}
size := h.reg.BlobSize(digest)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Docker-Content-Digest", digest)
w.WriteHeader(http.StatusOK)
}
// ─── GET /v2/{name}/blobs/{digest} ───────────────────────────────────────────
func (h *OCIRegistryHandler) getBlob(w http.ResponseWriter, r *http.Request, repo models.Repository, ociRepo *models.OCIRepository, digest string) {
if !h.reg.BlobExists(digest) {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
return
}
size := h.reg.BlobSize(digest)
f, err := h.reg.ReadBlob(digest)
if err != nil {
h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUnknown, "cannot read blob")
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Docker-Content-Digest", digest)
http.ServeContent(w, r, "", time.Time{}, f)
}
// ─── DELETE /v2/{name}/blobs/{digest} ────────────────────────────────────────
func (h *OCIRegistryHandler) deleteBlob(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, digest string) {
if !h.reg.BlobExists(digest) {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
return
}
_ = h.reg.DeleteBlob(digest)
h.db.Where("digest = ?", digest).Delete(&models.OCIBlob{})
w.WriteHeader(http.StatusAccepted)
}
// ─── POST /v2/{name}/blobs/uploads/ ──────────────────────────────────────────
func (h *OCIRegistryHandler) startUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository) {
uploadID := newOCIUploadID()
// Check for single-shot upload (body with ?digest param).
clientDigest := r.URL.Query().Get("digest")
contentLength := r.ContentLength
if clientDigest != "" && contentLength > 0 {
// Single-shot POST upload.
digest, size, err := h.reg.WriteBlob(r.Body)
if err != nil {
h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUploadInvalid, "upload failed")
return
}
h.upsertOCIName(ociRepo, digest, size)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", ociRepo.Name, digest))
w.Header().Set("Content-Range", fmt.Sprintf("0-%d", size-1))
w.Header().Set("Docker-Content-Digest", digest)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"digest": digest})
return
}
// Create upload session.
h.db.Insert(&models.OCIUpload{ //nolint:errcheck
UploadID: uploadID,
Name: ociRepo.Name,
ExpiresAt: time.Now().UTC().Add(30 * time.Minute),
})
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, uploadID))
w.Header().Set("Range", "0-0")
w.WriteHeader(http.StatusAccepted)
}
// ─── PATCH /v2/{name}/blobs/uploads/{uuid} ───────────────────────────────────
func (h *OCIRegistryHandler) patchUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
// Validate the upload session exists on disk.
uploadPath := h.reg.UploadPath(ref)
_, statErr := os.Stat(uploadPath)
if h.reg.UploadOffset(ref) == 0 && os.IsNotExist(statErr) {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUploadUnknown, "upload not found")
return
}
newOffset, err := h.reg.AppendUpload(ref, r.Body)
if err != nil {
h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUploadInvalid, "append failed")
return
}
// Persist upload offset.
h.db.Where("upload_id = ?", ref).Cols("offset").Update(&models.OCIUpload{Offset: newOffset})
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, ref))
w.Header().Set("Range", fmt.Sprintf("0-%d", newOffset-1))
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusAccepted)
}
// ─── PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:... ───────────────────
func (h *OCIRegistryHandler) finishUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
clientDigest := r.URL.Query().Get("digest")
if clientDigest == "" {
h.ociError(w, http.StatusBadRequest, oci.ErrDigestInvalid, "digest query parameter required")
return
}
// If there's a body, append it before finalising.
if r.ContentLength > 0 || r.Body != http.NoBody {
h.reg.AppendUpload(ref, r.Body) //nolint:errcheck
}
digest, size, err := h.reg.FinishUpload(ref, clientDigest)
if err != nil {
if _, ok := err.(*oci.DigestMismatch); ok {
h.ociError(w, http.StatusBadRequest, oci.ErrDigestInvalid, err.Error())
} else {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUploadUnknown, "upload not found")
}
return
}
h.upsertOCIName(ociRepo, digest, size)
// Remove upload session.
h.db.Where("upload_id = ?", ref).Delete(&models.OCIUpload{})
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", ociRepo.Name, digest))
w.Header().Set("Content-Range", fmt.Sprintf("0-%d", size-1))
w.Header().Set("Docker-Content-Digest", digest)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"digest": digest})
}
// ─── GET /v2/{name}/blobs/uploads/{uuid} ─────────────────────────────────────
func (h *OCIRegistryHandler) getUploadStatus(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
offset := h.reg.UploadOffset(ref)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, ref))
w.Header().Set("Range", fmt.Sprintf("0-%d", offset))
w.WriteHeader(http.StatusNoContent)
}
// ─── DELETE /v2/{name}/blobs/uploads/{uuid} ──────────────────────────────────
func (h *OCIRegistryHandler) cancelUpload(w http.ResponseWriter, r *http.Request, ref string) {
h.reg.CancelUpload(ref)
h.db.Where("upload_id = ?", ref).Delete(&models.OCIUpload{})
w.WriteHeader(http.StatusNoContent)
}
// ─── helpers ──────────────────────────────────────────────────────────────────
func (h *OCIRegistryHandler) getOrCreateOCIRepo(repoID int64, name string) (*models.OCIRepository, error) {
r := &models.OCIRepository{}
if found, _ := h.db.Where("name = ?", name).Get(r); found {
return r, nil
}
r.RepoID = repoID
r.Name = name
if _, err := h.db.Insert(r); err != nil {
return nil, err
}
return r, nil
}
func (h *OCIRegistryHandler) upsertOCIName(ociRepo *models.OCIRepository, digest string, size int64) {
// Track blob in DB if not already tracked.
h.db.Insert(&models.OCIBlob{Digest: digest, Size: size}) //nolint:errcheck
}
func (h *OCIRegistryHandler) ociError(w http.ResponseWriter, status int, code oci.ErrorCode, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(oci.NewError(code, msg)) //nolint:errcheck
}
// newOCIUploadID generates a random hex string used as the upload session ID.
func newOCIUploadID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic("oci: crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
func (h *OCIRegistryHandler) basicAuthOCI(r *http.Request) string {
u, pass, hasAuth := r.BasicAuth()
if !hasAuth {
return ""
}
var user models.User
if found, _ := h.db.Where("username = ?", u).Get(&user); !found {
return ""
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(pass)); err != nil {
return ""
}
return u
}
+1 -13
View File
@@ -247,19 +247,7 @@ func (h *PipelineHandler) RetryJob(w http.ResponseWriter, r *http.Request) {
// ── Helpers ───────────────────────────────────────────────────────────────────
func (h *PipelineHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
return resolveRepoID(h.db, w, r)
}
func (h *PipelineHandler) lookupRun(w http.ResponseWriter, r *http.Request) (*models.PipelineRun, bool) {
+1 -23
View File
@@ -18,29 +18,7 @@ func NewPRSettingsHandler(db *xorm.Engine) *PRSettingsHandler {
}
func (h *PRSettingsHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
return resolveRepo(h.db, w, r)
}
func (h *PRSettingsHandler) canManage(repo *models.Repository, callerID int64) bool {
+1 -17
View File
@@ -241,23 +241,7 @@ func (h *PRHandler) Update(w http.ResponseWriter, r *http.Request) {
}
func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
return resolveRepoID(h.db, w, r)
}
func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) {
+55
View File
@@ -0,0 +1,55 @@
package handlers
// repo_lookup.go — shared helper used by all handlers that resolve
// {owner}/{repo} URL params to a repository row.
//
// The owner segment can be either a user username (user-owned repo) or a
// workspace handle (workspace-owned repo). This tries user-namespace first,
// then workspace-namespace, so the lookup is always unambiguous.
import (
"net/http"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
// resolveRepoID resolves /{owner}/{repo} to a repository ID.
// It tries user namespace first, then workspace namespace.
// Returns (repoID, true) on success or writes a 404 and returns (0, false).
func resolveRepoID(db *xorm.Engine, w http.ResponseWriter, r *http.Request) (int64, bool) {
repo, ok := resolveRepo(db, w, r)
if !ok {
return 0, false
}
return repo.ID, true
}
// resolveRepo is the full repo lookup returning the Repository struct.
func resolveRepo(db *xorm.Engine, w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
// 1. Try user namespace.
var u models.User
if found, _ := db.Where("username = ?", ownerName).Get(&u); found {
var repo models.Repository
if found2, _ := db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); found2 {
return &repo, true
}
}
// 2. Try workspace namespace.
var ws models.Workspace
if found, _ := db.Where("handle = ?", ownerName).Get(&ws); found {
var repo models.Repository
if found2, _ := db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); found2 {
return &repo, true
}
}
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
+142
View File
@@ -0,0 +1,142 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/sbom"
)
type SBOMHandler struct {
db *xorm.Engine
generator *sbom.Generator
}
func NewSBOMHandler(db *xorm.Engine, gen *sbom.Generator) *SBOMHandler {
return &SBOMHandler{db: db, generator: gen}
}
// GetForRun returns the SBOM report metadata for a pipeline run.
// GET /api/v1/repos/{owner}/{repo}/runs/{runID}/sbom
func (h *SBOMHandler) GetForRun(w http.ResponseWriter, r *http.Request) {
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
if err != nil {
jsonError(w, "invalid run ID", http.StatusBadRequest)
return
}
report, err := h.generator.GetForRun(runID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if report == nil {
jsonError(w, "SBOM not yet generated for this run", http.StatusNotFound)
return
}
jsonOK(w, report)
}
// GetDocumentForRun streams the full CycloneDX JSON document for a run.
// GET /api/v1/repos/{owner}/{repo}/runs/{runID}/sbom/document
func (h *SBOMHandler) GetDocumentForRun(w http.ResponseWriter, r *http.Request) {
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
if err != nil {
jsonError(w, "invalid run ID", http.StatusBadRequest)
return
}
report, err := h.generator.GetForRun(runID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if report == nil {
jsonError(w, "SBOM not yet generated for this run", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/vnd.cyclonedx+json")
w.Header().Set("Content-Disposition", `attachment; filename="bom.json"`)
w.WriteHeader(http.StatusOK)
w.Write([]byte(report.BOMDocument)) //nolint:errcheck
}
// GetLatest returns the most recent SBOM report metadata for a repo.
// GET /api/v1/repos/{owner}/{repo}/sbom
func (h *SBOMHandler) GetLatest(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
report, err := h.generator.GetLatest(repoID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if report == nil {
jsonError(w, "no SBOM generated yet — push a commit or trigger a pipeline run", http.StatusNotFound)
return
}
jsonOK(w, report)
}
// GetLatestDocument streams the latest CycloneDX JSON for a repo.
// GET /api/v1/repos/{owner}/{repo}/sbom/document
func (h *SBOMHandler) GetLatestDocument(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
report, err := h.generator.GetLatest(repoID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if report == nil {
jsonError(w, "no SBOM generated yet", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/vnd.cyclonedx+json")
w.Header().Set("Content-Disposition", `attachment; filename="bom.json"`)
w.WriteHeader(http.StatusOK)
w.Write([]byte(report.BOMDocument)) //nolint:errcheck
}
// Generate triggers on-demand SBOM generation for a repo at a given ref/SHA.
// POST /api/v1/repos/{owner}/{repo}/sbom/generate?ref=<sha-or-branch>[&runID=<id>]
func (h *SBOMHandler) Generate(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
sha := r.URL.Query().Get("ref")
if sha == "" {
sha = r.URL.Query().Get("sha")
}
if sha == "" {
jsonError(w, "ref or sha query param required", http.StatusBadRequest)
return
}
var runID int64
if rid := r.URL.Query().Get("runID"); rid != "" {
runID, _ = strconv.ParseInt(rid, 10, 64)
}
report, err := h.generator.GenerateOnDemand(repoID, runID, sha)
if err != nil {
jsonError(w, "generation failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, report)
}
+78
View File
@@ -0,0 +1,78 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/domain/scanning"
"github.com/forgeo/forgebucket/internal/models"
)
type ScanningHandler struct {
db *xorm.Engine
scanner *scanning.Scanner
}
func NewScanningHandler(db *xorm.Engine, scanner *scanning.Scanner) *ScanningHandler {
return &ScanningHandler{db: db, scanner: scanner}
}
// ListSecrets returns all active (non-dismissed) secret leaks for a repo.
// GET /api/v1/repos/{owner}/{repo}/secrets/leaks
func (h *ScanningHandler) ListSecrets(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
leaks, err := h.scanner.ListFindings(repoID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
jsonOK(w, leaks)
}
// DismissSecrets acknowledges a leak so it no longer appears in active lists.
// POST /api/v1/repos/{owner}/{repo}/secrets/leaks/{leakID}/dismiss
func (h *ScanningHandler) DismissSecrets(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
_ = repoID
leakID, err := strconv.ParseInt(chi.URLParam(r, "leakID"), 10, 64)
if err != nil {
jsonError(w, "invalid leak ID", http.StatusBadRequest)
return
}
// Get current user from session for audit trail.
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
if err := h.scanner.DismissFindings(leakID, username); err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
jsonOK(w, map[string]string{"status": "dismissed"})
}
// ListAllSecrets returns active leaks across all repos (admin/workspace).
// GET /api/v1/secrets/leaks
func (h *ScanningHandler) ListAllSecrets(w http.ResponseWriter, r *http.Request) {
var leaks []models.SecretLeak
if err := h.db.Where("dismissed = ?", false).
OrderBy("detected_at DESC").Find(&leaks); err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if leaks == nil {
leaks = []models.SecretLeak{}
}
jsonOK(w, leaks)
}
+1 -23
View File
@@ -242,29 +242,7 @@ func ResolveSecretsForRun(db *xorm.Engine, repoID, workspaceID, envID int64, ses
// ── Helpers ───────────────────────────────────────────────────────────────────
func (h *SecretHandler) resolveRepoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
// Try workspace
var ws models.Workspace
if found2, _ := h.db.Where("handle = ?", owner).Get(&ws); !found2 {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found3, _ := h.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); !found3 {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
return resolveRepoID(h.db, w, r)
}
func (h *SecretHandler) resolveEnvID(w http.ResponseWriter, r *http.Request) (int64, bool) {
+2 -13
View File
@@ -6,7 +6,6 @@ import (
"strconv"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
@@ -69,23 +68,13 @@ type TimelineEvent struct {
//
// GET /api/v1/repos/:owner/:repo/timeline?limit=60
func (h *TimelineHandler) GetTimeline(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
limit := 60
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
limit = l
}
// ── Resolve repo ──────────────────────────────────────────────────────────
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return
}
+1 -13
View File
@@ -30,19 +30,7 @@ type accessTokenResponse struct {
}
func (h *AccessTokenHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
return resolveRepo(h.db, w, r)
}
func (h *AccessTokenHandler) canManage(repo *models.Repository, callerID int64) bool {
+97
View File
@@ -0,0 +1,97 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
"github.com/forgeo/forgebucket/internal/models"
)
type VulnScanHandler struct {
db *xorm.Engine
scanner *vulnscan.Scanner
}
func NewVulnScanHandler(db *xorm.Engine, scanner *vulnscan.Scanner) *VulnScanHandler {
return &VulnScanHandler{db: db, scanner: scanner}
}
// List returns all active vulnerability findings for a repo.
// GET /api/v1/repos/{owner}/{repo}/vulnerabilities
func (h *VulnScanHandler) List(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
findings, err := h.scanner.ListFindings(repoID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
jsonOK(w, findings)
}
// Scan triggers a full vulnerability scan against the latest SBOM.
// POST /api/v1/repos/{owner}/{repo}/vulnerabilities/scan
func (h *VulnScanHandler) Scan(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
findings, err := h.scanner.ScanSBOM(repoID)
if err != nil {
jsonError(w, "scan failed: "+err.Error(), http.StatusInternalServerError)
return
}
if findings == nil {
findings = []models.VulnerabilityFinding{}
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, findings)
}
// Dismiss acknowledges a vulnerability finding.
// POST /api/v1/repos/{owner}/{repo}/vulnerabilities/{findingID}/dismiss
func (h *VulnScanHandler) Dismiss(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
_ = repoID
findingID, err := strconv.ParseInt(chi.URLParam(r, "findingID"), 10, 64)
if err != nil {
jsonError(w, "invalid finding ID", http.StatusBadRequest)
return
}
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
if err := h.scanner.DismissFindings(findingID, username); err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
jsonOK(w, map[string]string{"status": "dismissed"})
}
// ListAll returns active findings across all repos.
// GET /api/v1/vulnerabilities
func (h *VulnScanHandler) ListAll(w http.ResponseWriter, r *http.Request) {
var findings []models.VulnerabilityFinding
if err := h.db.Where("dismissed = ?", false).
OrderBy("cvss_score DESC, detected_at DESC").Find(&findings); err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if findings == nil {
findings = []models.VulnerabilityFinding{}
}
jsonOK(w, findings)
}
+1 -13
View File
@@ -55,19 +55,7 @@ func toWebhookResp(wh models.Webhook) webhookResponse {
}
func (h *WebhookHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
return resolveRepo(h.db, w, r)
}
func (h *WebhookHandler) canManage(repo *models.Repository, callerID int64) bool {
+6 -12
View File
@@ -22,19 +22,13 @@ type WorkflowHandler struct{ db *xorm.Engine }
func NewWorkflowHandler(db *xorm.Engine) *WorkflowHandler { return &WorkflowHandler{db: db} }
func (h *WorkflowHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return nil, nil, false
}
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
return &repo, &owner, true
h.db.ID(repo.OwnerID).Get(&owner)
return repo, &owner, true
}
func (h *WorkflowHandler) canManage(repo *models.Repository, callerID int64) bool {
+69 -10
View File
@@ -14,18 +14,27 @@ import (
"github.com/gorilla/sessions"
"xorm.io/xorm"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/forgeo/forgebucket/internal/api/handlers"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/domain/sbom"
"github.com/forgeo/forgebucket/internal/domain/oci"
"github.com/forgeo/forgebucket/internal/domain/scanning"
"github.com/forgeo/forgebucket/internal/domain/signing"
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/observability"
)
func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus events.EventBus, artifactRoot string, staticFiles fs.FS) http.Handler {
func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus events.EventBus, artifactRoot string, staticFiles fs.FS, keys signing.KeyStore, sbomGen *sbom.Generator, ociRegistry *oci.Registry, scanner *scanning.Scanner, vulnScanner *vulnscan.Scanner) http.Handler {
r := chi.NewRouter()
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.RealIP)
r.Use(chimiddleware.Recoverer)
r.Use(observability.Middleware())
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:5173", cfg.InstanceURL},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
@@ -53,15 +62,23 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
webhookH := handlers.NewWebhookHandler(engine)
prSettingsH := handlers.NewPRSettingsHandler(engine)
lfsH := handlers.NewLFSHandler(engine)
exploreH := handlers.NewExploreHandler(engine)
dashH := handlers.NewDashboardHandler(engine)
auditH := handlers.NewAuditHandler(engine)
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
exploreH := handlers.NewExploreHandler(engine)
dashH := handlers.NewDashboardHandler(engine)
auditH := handlers.NewAuditHandler(engine)
healthH := handlers.NewHealthHandler(engine, bus)
repoHealthH := handlers.NewRepoHealthHandler(engine)
artifactH := handlers.NewArtifactHandler(engine, artifactRoot, &keys)
runnerH := handlers.NewRunnerHandler(engine)
gitopsH := handlers.NewGitOpsHandler(engine, bus)
fedH := handlers.NewFederationHandler(engine, cfg)
envH := handlers.NewEnvironmentHandler(engine, bus)
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret)
sbomH := handlers.NewSBOMHandler(engine, sbomGen)
ociH := handlers.NewOCIRegistryHandler(engine, ociRegistry)
scanH := handlers.NewScanningHandler(engine, scanner)
vulnH := handlers.NewVulnScanHandler(engine, vulnScanner)
// ── Git smart-HTTP transport ───────────────────────────────────────────────
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
@@ -73,17 +90,16 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Post("/git-receive-pack", gitH.ServeGit)
})
// ── Ops endpoints (root-level, no auth, standard paths for k8s/Prometheus) ──
r.Get("/health", healthH.Health)
r.Get("/metrics", promhttp.Handler().ServeHTTP)
r.Route("/api/v1", func(r chi.Router) {
// ── Public ────────────────────────────────────────────────────────────
r.Get("/explore/repos", exploreH.Repos)
r.Get("/explore/users", exploreH.Users)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Generates a CSRF token + cookie. SPA calls this once on load.
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
token, err := middleware.NewCSRFToken(w, !cfg.Debug)
@@ -108,6 +124,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/me", userH.Me)
r.Get("/dashboard", dashH.Get)
r.Get("/audit", auditH.List)
r.Get("/secrets/leaks", scanH.ListAllSecrets)
r.Get("/vulnerabilities", vulnH.ListAll)
r.Get("/pipelines/runs", pipeH.ListRecentRuns)
// Workspace routes
@@ -188,9 +206,13 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
})
r.Get("/artifacts", artifactH.List)
r.With(csrf).Post("/artifacts", artifactH.Upload)
r.Get("/sbom", sbomH.GetForRun)
r.Get("/sbom/document", sbomH.GetDocumentForRun)
})
})
r.Get("/artifacts/{artifactID}/download", artifactH.Download)
r.Get("/artifacts/{artifactID}/signature", artifactH.GetSignature)
r.Get("/artifacts/{artifactID}/verify", artifactH.VerifySignature)
r.Route("/members", func(r chi.Router) {
r.Get("/", memberH.List)
r.With(csrf).Post("/", memberH.Add)
@@ -237,8 +259,17 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/secrets", secretH.ListRepoSecrets)
r.With(csrf).Post("/secrets", secretH.UpsertRepoSecret)
r.With(csrf).Delete("/secrets/{name}", secretH.DeleteRepoSecret)
r.Get("/secrets/leaks", scanH.ListSecrets)
r.With(csrf).Post("/secrets/leaks/{leakID}/dismiss", scanH.DismissSecrets)
r.Get("/vulnerabilities", vulnH.List)
r.With(csrf).Post("/vulnerabilities/scan", vulnH.Scan)
r.With(csrf).Post("/vulnerabilities/{findingID}/dismiss", vulnH.Dismiss)
r.Get("/lfs-settings", lfsH.Get)
r.With(csrf).Put("/lfs-settings", lfsH.Update)
r.Get("/health", repoHealthH.Get)
r.Get("/sbom", sbomH.GetLatest)
r.Get("/sbom/document", sbomH.GetLatestDocument)
r.With(csrf).Post("/sbom/generate", sbomH.Generate)
r.Route("/environments", func(r chi.Router) {
r.Get("/", envH.ListEnvironments)
r.With(csrf).Post("/", envH.CreateEnvironment)
@@ -254,6 +285,15 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/secrets", secretH.ListEnvSecrets)
r.With(csrf).Post("/secrets", secretH.UpsertEnvSecret)
r.With(csrf).Delete("/secrets/{name}", secretH.DeleteEnvSecret)
r.Route("/gitops", func(r chi.Router) {
r.Get("/", gitopsH.GetConfig)
r.With(csrf).Put("/", gitopsH.UpsertConfig)
r.With(csrf).Delete("/", gitopsH.DeleteConfig)
r.With(csrf).Post("/sync", gitopsH.TriggerSync)
r.Get("/drift", gitopsH.GetDriftStatus)
r.Get("/drift/history", gitopsH.ListDriftEvents)
r.With(csrf).Post("/drift/{driftID}/acknowledge", gitopsH.AcknowledgeDrift)
})
})
})
})
@@ -263,6 +303,25 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.With(auth.Optional).Get("/ws", wsH.Hub)
// ── OCI Registry (Distribution Spec v1.1) ─────────────────────────────────
r.HandleFunc("/v2", ociH.ServeOCI)
r.HandleFunc("/v2/*", ociH.ServeOCI)
// ── ForgeFed Repository Actors (cross-instance PRs) ───────────────────────
// These must sit outside the auth-protected group since remote instances
// deliver activities without session cookies.
r.Get("/repos/{owner}/{repo}/actor", fedH.RepoActor)
r.Post("/repos/{owner}/{repo}/inbox", fedH.RepoInbox)
// ── ActivityPub / federation (root-level, no auth) ────────────────────────
// Must be registered before the /* catch-all so they are not proxied to Vite.
r.Get("/.well-known/webfinger", fedH.WebFinger)
r.Get("/users/{username}", fedH.Actor)
r.Post("/users/{username}/inbox", fedH.Inbox)
r.Get("/users/{username}/outbox", fedH.OutboxGet)
r.Get("/users/{username}/followers", fedH.Followers)
r.Get("/users/{username}/following", fedH.Following)
// In debug mode proxy non-API routes to the Vite dev server so :8080 works too.
// In production the built React app is embedded and served from staticFiles.
if cfg.Debug {
+28 -1
View File
@@ -30,10 +30,20 @@ type Config struct {
// Event bus
NATSUrl string
// GitOps
GitOpsReconcileInterval int // seconds between periodic drift checks; 0 disables
// Federation
InstanceURL string
InstanceName string
// Artifact signing (Phase 4)
// PEM-encoded ECDSA P-256 private key. If empty an ephemeral key is generated.
ArtifactSigningKey string
// OCI Registry
OCIRoot string
// Dev
Debug bool
}
@@ -46,7 +56,8 @@ func Load() (*Config, error) {
ArtifactRoot: getEnv("ARTIFACT_ROOT", filepath.Join(filepath.Dir(repoRoot), "artifacts")),
Debug: getEnvBool("DEBUG", false),
NATSUrl: getEnv("NATS_URL", ""),
NATSUrl: getEnv("NATS_URL", ""),
GitOpsReconcileInterval: getEnvInt("GITOPS_RECONCILE_INTERVAL", 300),
InstanceURL: getEnv("INSTANCE_URL", ""),
InstanceName: getEnv("INSTANCE_NAME", "ForgeBucket"),
}
@@ -57,6 +68,10 @@ func Load() (*Config, error) {
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
// Optional signing key
cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY")
cfg.OCIRoot = getEnv("OCI_ROOT", filepath.Join(filepath.Dir(cfg.RepoRoot), "oci"))
// Optional OIDC
cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER")
cfg.OIDCClientID = os.Getenv("OIDC_CLIENT_ID")
@@ -91,6 +106,18 @@ func getEnv(key, fallback string) string {
return fallback
}
func getEnvInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
func getEnvBool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
+14 -5
View File
@@ -9,6 +9,7 @@ import (
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/sbom"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
@@ -17,12 +18,13 @@ import (
// advances the DAG as jobs complete. It does NOT execute jobs directly —
// that is the RunnerManager's responsibility.
type Orchestrator struct {
db *xorm.Engine
bus events.EventBus
db *xorm.Engine
bus events.EventBus
sbomGen *sbom.Generator
}
func NewOrchestrator(db *xorm.Engine, bus events.EventBus) *Orchestrator {
return &Orchestrator{db: db, bus: bus}
func NewOrchestrator(db *xorm.Engine, bus events.EventBus, sbomGen *sbom.Generator) *Orchestrator {
return &Orchestrator{db: db, bus: bus, sbomGen: sbomGen}
}
// Start subscribes to relevant NATS subjects and blocks until ctx is cancelled.
@@ -142,7 +144,11 @@ func (o *Orchestrator) createRun(repo models.Repository, evt events.PushEvent, w
// Create job + step records for every job in the workflow.
for jobName, wfJob := range wf.Jobs {
needsJSON, _ := json.Marshal([]string(wfJob.Needs))
needs := []string(wfJob.Needs)
if needs == nil {
needs = []string{}
}
needsJSON, _ := json.Marshal(needs)
job := &models.PipelineJob{
RunID: run.ID,
Name: jobName,
@@ -231,6 +237,9 @@ func (o *Orchestrator) advanceDAG(runID, jobID int64, result string) {
run.FinishedAt = &now
o.db.ID(run.ID).Cols("status", "finished_at").Update(&run) //nolint:errcheck
o.bus.Publish(events.SubjectPipelineCompleted, events.PipelineEvent{RunID: run.ID, RepoID: run.RepoID, Status: "succeeded", At: now}) //nolint:errcheck
if o.sbomGen != nil {
go o.sbomGen.GenerateOnDemand(run.RepoID, run.ID, run.TriggerSHA)
}
return
}
+84
View File
@@ -0,0 +1,84 @@
package federation
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"strings"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
// APID returns the canonical ActivityPub actor ID for a local username.
func APID(instanceURL, username string) string {
return strings.TrimRight(instanceURL, "/") + "/users/" + username
}
// GetOrCreate fetches the FederationActor for a user, creating it with a fresh
// RSA-2048 key pair if none exists. Actor URLs are derived from instanceURL.
func GetOrCreate(db *xorm.Engine, userID int64, username, instanceURL string) (*models.FederationActor, error) {
var actor models.FederationActor
if found, _ := db.Where("user_id = ?", userID).Get(&actor); found {
return &actor, nil
}
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generate rsa key: %w", err)
}
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(priv),
})
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshal public key: %w", err)
}
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
base := APID(instanceURL, username)
actor = models.FederationActor{
UserID: userID,
APID: base,
InboxURL: base + "/inbox",
OutboxURL: base + "/outbox",
PublicKey: string(pubPEM),
PrivateKey: string(privPEM),
}
if _, err := db.Insert(&actor); err != nil {
// Race condition: another goroutine may have just created it.
if found, _ := db.Where("user_id = ?", userID).Get(&actor); found {
return &actor, nil
}
return nil, fmt.Errorf("insert actor: %w", err)
}
return &actor, nil
}
// ActorJSON builds the JSON-LD actor document returned by GET /users/{username}.
func ActorJSON(actor *models.FederationActor, username, displayName string) map[string]any {
return map[string]any{
"@context": []any{
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
},
"id": actor.APID,
"type": "Person",
"preferredUsername": username,
"name": displayName,
"inbox": actor.InboxURL,
"outbox": actor.OutboxURL,
"followers": actor.APID + "/followers",
"following": actor.APID + "/following",
"publicKey": map[string]any{
"id": actor.APID + "#main-key",
"owner": actor.APID,
"publicKeyPem": actor.PublicKey,
},
}
}
+224
View File
@@ -0,0 +1,224 @@
package federation
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
// RepoAPID returns the ActivityPub actor ID for a repository.
// Format: {instanceURL}/repos/{owner}/{name}
func RepoAPID(instanceURL, owner, name string) string {
return strings.TrimRight(instanceURL, "/") + "/repos/" + owner + "/" + name
}
// RepoActorJSON builds the JSON-LD actor document for a ForgeFed Repository actor.
func RepoActorJSON(owner, name, description, instanceURL string) map[string]any {
apid := RepoAPID(instanceURL, owner, name)
return map[string]any{
"@context": []any{
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
map[string]string{
"Repository": "https://www.w3.org/ns/activitystreams#Repository",
},
},
"id": apid,
"type": "Repository",
"preferredUsername": name,
"name": owner + "/" + name,
"summary": description,
"inbox": apid + "/inbox",
"outbox": apid + "/outbox",
"followers": apid + "/followers",
"following": apid + "/following",
}
}
// HandleCreatePullRequest processes an incoming Create activity whose object
// is a PullRequest (per the ForgeFed vocabulary). It creates a local PR record
// in the target repository for the cross-instance proposal.
func HandleCreatePullRequest(db *xorm.Engine, body []byte, instanceURL string) error {
var activity struct {
Actor string `json:"actor"`
Object struct {
Type string `json:"type"`
Summary string `json:"summary"`
Content string `json:"content"`
Source *struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"source"`
Target *struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"target"`
} `json:"object"`
}
if err := json.Unmarshal(body, &activity); err != nil {
return fmt.Errorf("parse activity: %w", err)
}
if activity.Object.Type != "PullRequest" {
return nil
}
// Extract target repository info from the object's target.
targetID := activity.Object.Target.ID
targetParts := strings.Split(strings.TrimRight(targetID, "/"), "/")
if len(targetParts) < 2 {
return fmt.Errorf("cannot parse target repo APID: %s", targetID)
}
// Last two segments should be owner/repo-name.
repoOwner := targetParts[len(targetParts)-2]
repoName := targetParts[len(targetParts)-1]
// Resolve the target repository.
var repo models.Repository
found, err := db.Where("name = ?", repoName).
Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", repoOwner).
Get(&repo)
if err != nil {
return fmt.Errorf("database error: %w", err)
}
if !found {
return fmt.Errorf("target repo %s/%s not found on this instance", repoOwner, repoName)
}
// Resolve or create a FederationActor for the repo owner (needed for key ops).
var ownerUser models.User
if found, _ := db.Where("username = ?", repoOwner).Get(&ownerUser); !found {
return fmt.Errorf("owner user %s not found", repoOwner)
}
localActor, err := GetOrCreate(db, ownerUser.ID, repoOwner, instanceURL)
if err != nil {
return fmt.Errorf("get actor: %w", err)
}
// Determine the PR title and body.
title := activity.Object.Summary
if title == "" {
title = fmt.Sprintf("Cross-instance PR from %s", activity.Actor)
}
bodyContent := activity.Object.Content
if bodyContent == "" {
bodyContent = fmt.Sprintf("Pull request proposed via ActivityPub from %s", activity.Actor)
}
// Create the PR. For cross-instance PRs, authorID is set to the target
// repo owner (we can't create a user for the remote actor automatically).
// The RemoteSource field records the source repository APID.
pr := &models.PullRequest{
RepoID: repo.ID,
AuthorID: ownerUser.ID,
Title: title,
Body: bodyContent,
SourceBranch: "refs/for/main",
TargetBranch: "main",
Status: models.PRStatusOpen,
RemoteSource: activity.Actor,
}
// Try to extract source branch from the source repo.
if activity.Object.Source != nil {
sourceID := activity.Object.Source.ID
if sourceID != "" {
pr.RemoteSource = sourceID
}
if activity.Object.Source.Name != "" {
pr.SourceBranch = activity.Object.Source.Name
}
}
if _, err := db.Insert(pr); err != nil {
return fmt.Errorf("insert PR: %w", err)
}
// Persist the outbound Accept for the PR activity so the remote knows
// we received it (we auto-accept all incoming PRs).
accept := map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": localActor.APID + "/activities/accept-pr-" + fmt.Sprint(time.Now().UnixNano()),
"type": "Accept",
"actor": localActor.APID,
}
acceptJSON, _ := json.Marshal(accept)
db.Insert(&models.FederationActivity{ //nolint:errcheck
ActorAPID: localActor.APID,
Type: "Accept",
ObjectJSON: string(acceptJSON),
Direction: "outbound",
RemoteActor: activity.Actor,
Published: time.Now().UTC(),
})
log.Printf("forgefed: created PR %d from cross-instance actor %s", pr.ID, activity.Actor)
return nil
}
// SendCreatePullRequest delivers a Create(PullRequest) activity to a remote
// instance's inbox. The remote inbox URL is derived from the forked-from repo's
// APID by appending /inbox.
func SendCreatePullRequest(db *xorm.Engine, localActor *models.FederationActor, pr *models.PullRequest, remoteAPID, instanceURL string) error {
// Build the Create(PullRequest) activity.
activity := map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": localActor.APID + "/activities/create-pr-" + fmt.Sprint(time.Now().UnixNano()),
"type": "Create",
"actor": localActor.APID,
"object": map[string]any{
"type": "PullRequest",
"id": localActor.APID + "/pull-requests/" + fmt.Sprint(pr.ID),
"summary": pr.Title,
"content": pr.Body,
"source": map[string]any{
"type": "Repository",
"id": localActor.APID,
},
"target": map[string]any{
"type": "Repository",
"id": remoteAPID,
},
},
"to": []string{remoteAPID + "/inbox", "https://www.w3.org/ns/activitystreams#Public"},
}
remoteInbox := strings.TrimSuffix(remoteAPID, "/") + "/inbox"
if err := DeliverActivity(localActor, activity, remoteInbox); err != nil {
return fmt.Errorf("deliver PR to %s: %w", remoteInbox, err)
}
actJSON, _ := json.Marshal(activity)
db.Insert(&models.FederationActivity{ //nolint:errcheck
ActorAPID: localActor.APID,
Type: "Create",
ObjectJSON: string(actJSON),
Direction: "outbound",
RemoteActor: remoteAPID,
Published: time.Now().UTC(),
})
log.Printf("forgefed: sent Create(PullRequest) for PR %d to %s", pr.ID, remoteInbox)
return nil
}
// IsCreatePullRequest checks whether the given body is a Create(PullRequest) activity.
func IsCreatePullRequest(body []byte) bool {
var check struct {
Type string `json:"type"`
Object struct {
Type string `json:"type"`
} `json:"object"`
}
if err := json.Unmarshal(body, &check); err != nil {
return false
}
return check.Type == "Create" && check.Object.Type == "PullRequest"
}
+100
View File
@@ -0,0 +1,100 @@
package federation
import (
"testing"
)
func TestRepoAPID(t *testing.T) {
apid := RepoAPID("https://example.com", "alice", "myrepo")
expected := "https://example.com/repos/alice/myrepo"
if apid != expected {
t.Errorf("got %q, want %q", apid, expected)
}
}
func TestRepoAPID_TrailingSlash(t *testing.T) {
apid := RepoAPID("https://example.com/", "bob", "app")
expected := "https://example.com/repos/bob/app"
if apid != expected {
t.Errorf("got %q, want %q", apid, expected)
}
}
func TestRepoActorJSON(t *testing.T) {
doc := RepoActorJSON("alice", "myrepo", "A cool repo", "https://example.com")
if doc["type"] != "Repository" {
t.Errorf("type = %v, want Repository", doc["type"])
}
if doc["preferredUsername"] != "myrepo" {
t.Errorf("preferredUsername = %v", doc["preferredUsername"])
}
if doc["name"] != "alice/myrepo" {
t.Errorf("name = %v", doc["name"])
}
if doc["summary"] != "A cool repo" {
t.Errorf("summary = %v", doc["summary"])
}
inbox, ok := doc["inbox"].(string)
if !ok || inbox != "https://example.com/repos/alice/myrepo/inbox" {
t.Errorf("inbox = %v", inbox)
}
outbox, ok := doc["outbox"].(string)
if !ok || outbox != "https://example.com/repos/alice/myrepo/outbox" {
t.Errorf("outbox = %v", outbox)
}
}
func TestIsCreatePullRequest(t *testing.T) {
tests := []struct {
name string
body []byte
want bool
}{
{
name: "valid Create(PullRequest)",
body: []byte(`{"type":"Create","object":{"type":"PullRequest","summary":"fix bug"}}`),
want: true,
},
{
name: "Create with non-PR object",
body: []byte(`{"type":"Create","object":{"type":"Note"}}`),
want: false,
},
{
name: "Follow activity",
body: []byte(`{"type":"Follow","object":"https://example.com/users/alice"}`),
want: false,
},
{
name: "invalid JSON",
body: []byte(`not json`),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsCreatePullRequest(tt.body); got != tt.want {
t.Errorf("IsCreatePullRequest() = %v, want %v", got, tt.want)
}
})
}
}
func TestExtractInstanceURL(t *testing.T) {
tests := []struct {
apid string
want string
}{
{"https://example.com/users/alice", "https://example.com"},
{"http://localhost:8080/users/bob", "http://localhost:8080"},
{"https://forge.example.org/users/charlie", "https://forge.example.org"},
}
for _, tt := range tests {
t.Run(tt.apid, func(t *testing.T) {
if got := extractInstanceURL(tt.apid); got != tt.want {
t.Errorf("extractInstanceURL() = %q, want %q", got, tt.want)
}
})
}
}
+134
View File
@@ -0,0 +1,134 @@
package federation
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
// Receive persists an inbound activity and dispatches it by type.
// The caller is responsible for verifying the HTTP signature before calling this.
func Receive(db *xorm.Engine, localActor *models.FederationActor, body []byte) error {
var activity map[string]any
if err := json.Unmarshal(body, &activity); err != nil {
return fmt.Errorf("parse activity: %w", err)
}
actType, _ := activity["type"].(string)
actorAPID, _ := activity["actor"].(string)
entry := &models.FederationActivity{
ActorAPID: localActor.APID,
Type: actType,
ObjectJSON: string(body),
Direction: "inbound",
RemoteActor: actorAPID,
Published: time.Now().UTC(),
}
db.Insert(entry) //nolint:errcheck
switch actType {
case "Follow":
return handleFollow(db, localActor, activity, actorAPID)
case "Accept":
handleAccept(db, localActor, activity)
case "Undo":
handleUndo(db, localActor, activity)
case "Create":
if IsCreatePullRequest(body) {
// Derive instanceURL from the local actor's APID.
instanceURL := extractInstanceURL(localActor.APID)
if err := HandleCreatePullRequest(db, body, instanceURL); err != nil {
log.Printf("federation: handle Create(PullRequest): %v", err)
}
} else {
log.Printf("federation: received Create activity from %s (non-PR, skipped)", actorAPID)
}
default:
log.Printf("federation: received unhandled activity type %q from %s", actType, actorAPID)
}
return nil
}
// handleFollow auto-accepts all incoming Follow activities and sends an Accept
// back to the sender's inbox.
func handleFollow(db *xorm.Engine, localActor *models.FederationActor, follow map[string]any, followerAPID string) error {
if followerAPID == "" {
return fmt.Errorf("Follow activity missing actor field")
}
// Fetch the follower's remote actor to get their inbox URL.
remote, err := FetchActor(db, followerAPID)
if err != nil {
return fmt.Errorf("fetch follower actor: %w", err)
}
if remote.InboxURL == "" {
return fmt.Errorf("follower has no inbox URL")
}
// Build Accept activity.
accept := map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": localActor.APID + "/activities/accept-" + fmt.Sprint(time.Now().UnixNano()),
"type": "Accept",
"actor": localActor.APID,
"object": follow,
}
// Deliver asynchronously so inbox handler returns quickly.
go func() {
if err := DeliverActivity(localActor, accept, remote.InboxURL); err != nil {
log.Printf("federation: deliver Accept to %s: %v", remote.InboxURL, err)
return
}
// Store the outbound Accept.
db.Insert(&models.FederationActivity{ //nolint:errcheck
ActorAPID: localActor.APID,
Type: "Accept",
ObjectJSON: mustJSON(accept),
Direction: "outbound",
RemoteActor: followerAPID,
Published: time.Now().UTC(),
})
log.Printf("federation: accepted Follow from %s", followerAPID)
}()
return nil
}
func handleAccept(db *xorm.Engine, localActor *models.FederationActor, activity map[string]any) {
// A remote actor accepted our Follow. Nothing to store beyond the inbox entry.
log.Printf("federation: received Accept for actor %s", localActor.APID)
}
func handleUndo(db *xorm.Engine, localActor *models.FederationActor, activity map[string]any) {
// Common case: undo a Follow (unfollow).
obj, _ := activity["object"].(map[string]any)
if obj == nil {
return
}
if t, _ := obj["type"].(string); t == "Follow" {
log.Printf("federation: received Undo(Follow) for actor %s", localActor.APID)
}
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func extractInstanceURL(apid string) string {
// apid is like "https://example.com/users/alice"
// Return "https://example.com"
parts := strings.SplitN(apid, "/", 4)
if len(parts) >= 3 {
return parts[0] + "//" + parts[2]
}
return apid
}
+84
View File
@@ -0,0 +1,84 @@
package federation
import (
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
const activitiesPerPage = 20
// Collection builds an ActivityStreams OrderedCollection (or page) for an actor's outbox.
// page=0 returns the collection summary; page≥1 returns a paginated OrderedCollectionPage.
func Collection(db *xorm.Engine, actorAPID string, outboxURL string, page int) map[string]any {
total, _ := db.Where("actor_ap_id = ? AND direction = 'outbound'", actorAPID).
Count(&models.FederationActivity{})
if page == 0 {
return map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": outboxURL,
"type": "OrderedCollection",
"totalItems": total,
"first": outboxURL + "?page=1",
}
}
offset := (page - 1) * activitiesPerPage
var activities []models.FederationActivity
db.Where("actor_ap_id = ? AND direction = 'outbound'", actorAPID).
Desc("published").
Limit(activitiesPerPage, offset).
Find(&activities)
items := make([]any, 0, len(activities))
for _, a := range activities {
items = append(items, rawJSON(a.ObjectJSON))
}
coll := map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": outboxURL + "?page=" + itoa(page),
"type": "OrderedCollectionPage",
"partOf": outboxURL,
"orderedItems": items,
}
if int64(offset+activitiesPerPage) < total {
coll["next"] = outboxURL + "?page=" + itoa(page+1)
}
return coll
}
// StubCollection returns a minimal OrderedCollection with zero items.
// Used for followers/following until the social graph is implemented.
func StubCollection(collectionURL string) map[string]any {
return map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": collectionURL,
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []any{},
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
result := ""
for n > 0 {
result = string(rune('0'+n%10)) + result
n /= 10
}
return result
}
// rawJSON wraps a JSON string so it marshals as-is (not double-encoded).
type rawJSON string
func (r rawJSON) MarshalJSON() ([]byte, error) {
if r == "" {
return []byte("null"), nil
}
return []byte(r), nil
}
+121
View File
@@ -0,0 +1,121 @@
package federation
import (
"encoding/json"
"fmt"
"net/http"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
const activityJSONType = "application/activity+json"
// FetchActor retrieves a remote actor document by APID. If it is already cached
// in the remote_actor table it is returned immediately; otherwise it is fetched
// over HTTP and persisted before returning.
func FetchActor(db *xorm.Engine, apid string) (*models.RemoteActor, error) {
var cached models.RemoteActor
if found, _ := db.Where("ap_id = ?", apid).Get(&cached); found {
return &cached, nil
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", apid, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", activityJSONType+", application/ld+json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch actor %s: %w", apid, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch actor %s: HTTP %d", apid, resp.StatusCode)
}
var doc map[string]any
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, fmt.Errorf("decode actor: %w", err)
}
inbox, _ := doc["inbox"].(string)
pubKey := extractPublicKeyPEM(doc)
actor := &models.RemoteActor{
APID: apid,
InboxURL: inbox,
PublicKey: pubKey,
FetchedAt: time.Now().UTC(),
}
// Upsert: ignore duplicate key errors (concurrent fetch).
db.Insert(actor) //nolint:errcheck
// Reload to get the DB-assigned ID.
db.Where("ap_id = ?", apid).Get(actor) //nolint:errcheck
return actor, nil
}
// DeliverActivity POSTs a signed activity to a remote actor's inbox.
func DeliverActivity(localActor *models.FederationActor, activity map[string]any, recipientInbox string) error {
body, err := json.Marshal(activity)
if err != nil {
return fmt.Errorf("marshal activity: %w", err)
}
req, err := http.NewRequest("POST", recipientInbox, jsonReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", activityJSONType)
req.Header.Set("Accept", activityJSONType)
if err := Sign(req, localActor.APID+"#main-key", localActor.PrivateKey); err != nil {
return fmt.Errorf("sign: %w", err)
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("deliver to %s: %w", recipientInbox, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("deliver to %s: HTTP %d", recipientInbox, resp.StatusCode)
}
return nil
}
func extractPublicKeyPEM(doc map[string]any) string {
pk, ok := doc["publicKey"].(map[string]any)
if !ok {
return ""
}
pem, _ := pk["publicKeyPem"].(string)
return pem
}
// jsonReader wraps a byte slice in a reader that io.ReadCloser can use.
func jsonReader(data []byte) *bytesReader {
return &bytesReader{data: data, pos: 0}
}
type bytesReader struct {
data []byte
pos int
}
func (r *bytesReader) Read(p []byte) (int, error) {
if r.pos >= len(r.data) {
return 0, fmt.Errorf("EOF")
}
n := copy(p, r.data[r.pos:])
r.pos += n
return n, nil
}
+175
View File
@@ -0,0 +1,175 @@
package federation
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"strings"
"time"
"xorm.io/xorm"
)
// Sign adds an HTTP Signature header to req using the RSA private key.
// Implements draft-cavage-http-signatures (the fediverse de-facto standard).
// Signs: (request-target), host, date. If body is set, also signs digest.
func Sign(req *http.Request, keyID, privateKeyPEM string) error {
if req.Header.Get("Date") == "" {
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
}
if req.Header.Get("Host") == "" {
req.Header.Set("Host", req.URL.Host)
}
method := strings.ToLower(req.Method)
target := req.URL.RequestURI()
signingString := fmt.Sprintf("(request-target): %s %s\nhost: %s\ndate: %s",
method, target,
req.Header.Get("Host"),
req.Header.Get("Date"),
)
headers := "(request-target) host date"
priv, err := parsePrivateKey(privateKeyPEM)
if err != nil {
return fmt.Errorf("parse private key: %w", err)
}
h := sha256.Sum256([]byte(signingString))
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:])
if err != nil {
return fmt.Errorf("sign: %w", err)
}
req.Header.Set("Signature", fmt.Sprintf(
`keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"`,
keyID, headers, base64.StdEncoding.EncodeToString(sig),
))
return nil
}
// Verify validates the HTTP Signature header on an incoming request.
// It fetches the sender's public key from their actor document (or the local DB).
func Verify(r *http.Request, db *xorm.Engine, instanceURL string) error {
sigHeader := r.Header.Get("Signature")
if sigHeader == "" {
return fmt.Errorf("missing Signature header")
}
params := parseSignatureHeader(sigHeader)
keyID := params["keyId"]
sigB64 := params["signature"]
headersList := params["headers"]
if keyID == "" || sigB64 == "" {
return fmt.Errorf("malformed Signature header")
}
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
return fmt.Errorf("decode signature: %w", err)
}
// Fetch the public key for this keyId.
// keyId is typically "{actorURL}#main-key" — strip the fragment to get the actor APID.
actorAPID := strings.SplitN(keyID, "#", 2)[0]
pubKeyPEM, err := resolvePublicKey(db, actorAPID, instanceURL)
if err != nil {
return fmt.Errorf("resolve public key for %s: %w", actorAPID, err)
}
// Reconstruct the signing string from the request.
signedHeaders := strings.Fields(headersList)
if len(signedHeaders) == 0 {
signedHeaders = []string{"date"}
}
var parts []string
for _, h := range signedHeaders {
switch h {
case "(request-target)":
parts = append(parts, fmt.Sprintf("(request-target): %s %s",
strings.ToLower(r.Method), r.URL.RequestURI()))
default:
parts = append(parts, h+": "+r.Header.Get(http.CanonicalHeaderKey(h)))
}
}
signingString := strings.Join(parts, "\n")
pub, err := parsePublicKey(pubKeyPEM)
if err != nil {
return fmt.Errorf("parse public key: %w", err)
}
h := sha256.Sum256([]byte(signingString))
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], sig); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
return nil
}
// ── helpers ──────────────────────────────────────────────────────────────────
func parseSignatureHeader(header string) map[string]string {
params := make(map[string]string)
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
idx := strings.Index(part, "=")
if idx < 0 {
continue
}
key := strings.TrimSpace(part[:idx])
val := strings.Trim(strings.TrimSpace(part[idx+1:]), `"`)
params[key] = val
}
return params
}
func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
func parsePublicKey(pemStr string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an RSA public key")
}
return rsaPub, nil
}
// resolvePublicKey returns the public key PEM for an actor APID.
// Checks local actors first, then remote cache, then fetches from network.
func resolvePublicKey(db *xorm.Engine, actorAPID, instanceURL string) (string, error) {
// Check if it's a local actor.
var local struct {
PublicKey string `xorm:"public_key"`
}
if found, _ := db.Table("federation_actor").
Where("ap_id = ?", actorAPID).
Cols("public_key").Get(&local); found && local.PublicKey != "" {
return local.PublicKey, nil
}
// Fetch (and cache) from network.
remote, err := FetchActor(db, actorAPID)
if err != nil {
return "", err
}
return remote.PublicKey, nil
}
+17
View File
@@ -283,6 +283,23 @@ func RepoSize(repoPath string) int64 {
return total
}
// Run executes a git command in repoPath with discrete arguments and returns
// the raw stdout. WARNING: args must be constant literals or strictly validated
// — no user-controlled values belong here. This is the public equivalent of the
// internal run() helper and carries the same safety guarantees.
func Run(repoPath string, args ...string) ([]byte, error) {
return run(repoPath, args...)
}
// RevParse resolves a ref (branch name, tag, or SHA) to its full commit SHA.
func RevParse(repoPath, ref string) (string, error) {
out, err := run(repoPath, "rev-parse", "--verify", ref)
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// SetDefaultBranch updates HEAD to point at the given branch name.
func SetDefaultBranch(repoPath, branch string) error {
_, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
+95
View File
@@ -0,0 +1,95 @@
package gitops
import (
"context"
"encoding/json"
"log"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// Controller is the GitOps reconciliation engine. It subscribes to NATS events
// and drives drift detection + auto-sync for every configured environment.
type Controller struct {
db *xorm.Engine
bus events.EventBus
cfg *config.Config
}
func NewController(db *xorm.Engine, bus events.EventBus, cfg *config.Config) *Controller {
return &Controller{db: db, bus: bus, cfg: cfg}
}
// Start subscribes to relevant events and blocks until ctx is cancelled.
func (c *Controller) Start(ctx context.Context) {
c.recoverSyncingState()
unsub1, err := c.bus.Subscribe(events.SubjectPushReceived, func(_ string, data []byte) {
var evt events.PushEvent
if err := json.Unmarshal(data, &evt); err != nil {
log.Printf("gitops: bad push.received payload: %v", err)
return
}
go c.handlePush(evt)
})
if err != nil {
log.Printf("gitops: subscribe push.received: %v", err)
} else {
defer unsub1()
}
unsub2, err := c.bus.Subscribe(events.SubjectDeploymentSucceeded, func(_ string, data []byte) {
go c.handleDeploymentSucceeded(data)
})
if err != nil {
log.Printf("gitops: subscribe deployment.succeeded: %v", err)
} else {
defer unsub2()
}
unsub3, err := c.bus.Subscribe(events.SubjectDeploymentFailed, func(_ string, data []byte) {
go c.handleDeploymentFailed(data)
})
if err != nil {
log.Printf("gitops: subscribe deployment.failed: %v", err)
} else {
defer unsub3()
}
if c.cfg.GitOpsReconcileInterval > 0 {
go c.runTicker(ctx)
}
log.Printf("gitops: controller started (reconcile interval: %ds)", c.cfg.GitOpsReconcileInterval)
<-ctx.Done()
}
func (c *Controller) runTicker(ctx context.Context) {
interval := time.Duration(c.cfg.GitOpsReconcileInterval) * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.periodicCheck()
case <-ctx.Done():
return
}
}
}
// recoverSyncingState marks any configs left in "syncing" as "drifted" on startup
// (they were in-flight when the server last stopped).
func (c *Controller) recoverSyncingState() {
affected, _ := c.db.Where("sync_status = 'syncing'").
Cols("sync_status").
Update(&models.GitOpsConfig{SyncStatus: "drifted"})
if affected > 0 {
log.Printf("gitops: recovered %d stale syncing configs → drifted", affected)
}
}
+168
View File
@@ -0,0 +1,168 @@
package gitops
import (
"log"
"strings"
"time"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// CheckDrift resolves the HEAD SHA of branch in the repo at repoPath and
// compares it against actualSHA. Returns the resolved HEAD SHA, whether drift
// exists, and any error.
func CheckDrift(repoPath, branch, actualSHA string) (desiredSHA string, drifted bool, err error) {
sha, err := gitdomain.RevParse(repoPath, branch)
if err != nil {
return "", false, err
}
return sha, sha != actualSHA, nil
}
// refToBranch strips the refs/heads/ prefix from a full git ref.
// Returns "" for non-branch refs (tags, etc.).
func refToBranch(ref string) string {
return strings.TrimPrefix(ref, "refs/heads/")
}
// handlePush is called on every push.received event. For each GitOpsConfig
// on the pushed repo whose branch matches, it runs a drift check.
func (c *Controller) handlePush(evt events.PushEvent) {
pushedBranch := refToBranch(evt.Ref)
if pushedBranch == "" {
return // tag push or other non-branch ref — ignore
}
var cfgs []models.GitOpsConfig
if err := c.db.Where("repo_id = ?", evt.RepoID).Find(&cfgs); err != nil {
return
}
for _, cfg := range cfgs {
if cfg.Branch != pushedBranch {
continue
}
c.evaluateDrift(cfg, evt.After)
}
}
// evaluateDrift compares desiredSHA against the config's ActualSHA and takes
// the appropriate action: record drift and optionally auto-sync.
func (c *Controller) evaluateDrift(cfg models.GitOpsConfig, desiredSHA string) {
now := time.Now().UTC()
cfg.LastCheckedAt = &now
cfg.DesiredSHA = desiredSHA
if desiredSHA == cfg.ActualSHA {
// Already in sync.
cfg.SyncStatus = "synced"
c.db.ID(cfg.ID).Cols("sync_status", "desired_sha", "last_checked_at").Update(&cfg) //nolint:errcheck
return
}
// Drift detected — record and publish.
log.Printf("gitops: drift on env %d: desired=%s actual=%s", cfg.EnvID, desiredSHA[:7], sha7(cfg.ActualSHA))
drift := &models.GitOpsDriftEvent{
EnvID: cfg.EnvID,
RepoID: cfg.RepoID,
DesiredSHA: desiredSHA,
ActualSHA: cfg.ActualSHA,
SyncStatus: "drifted",
DetectedAt: now,
}
c.db.Insert(drift) //nolint:errcheck
cfg.SyncStatus = "drifted"
c.db.ID(cfg.ID).Cols("sync_status", "desired_sha", "last_checked_at").Update(&cfg) //nolint:errcheck
// Look up env name for the event payload.
var env models.Environment
c.db.ID(cfg.EnvID).Get(&env) //nolint:errcheck
c.bus.Publish(events.SubjectEnvironmentDriftDetected, events.DriftEvent{ //nolint:errcheck
EnvID: cfg.EnvID,
EnvName: env.Name,
RepoID: cfg.RepoID,
DesiredSHA: desiredSHA,
ActualSHA: cfg.ActualSHA,
At: now,
})
if cfg.AutoSync {
c.TriggerSync(cfg, desiredSHA)
}
}
// periodicCheck runs on a ticker and re-evaluates drift for every GitOpsConfig
// whose SyncInterval has elapsed.
func (c *Controller) periodicCheck() {
now := time.Now().UTC()
var cfgs []models.GitOpsConfig
if err := c.db.Where("sync_interval > 0").Find(&cfgs); err != nil {
return
}
for _, cfg := range cfgs {
elapsed := now.Unix() - lastChecked(cfg).Unix()
if int(elapsed) < cfg.SyncInterval {
continue
}
var repo models.Repository
if found, _ := c.db.ID(cfg.RepoID).Get(&repo); !found {
continue
}
desiredSHA, drifted, err := CheckDrift(repo.DiskPath, cfg.Branch, cfg.ActualSHA)
if err != nil {
log.Printf("gitops: periodic check env %d: %v", cfg.EnvID, err)
now2 := time.Now().UTC()
cfg.LastCheckedAt = &now2
c.db.ID(cfg.ID).Cols("last_checked_at").Update(&cfg) //nolint:errcheck
continue
}
if drifted {
c.evaluateDrift(cfg, desiredSHA)
} else {
now2 := time.Now().UTC()
cfg.LastCheckedAt = &now2
c.db.ID(cfg.ID).Cols("last_checked_at").Update(&cfg) //nolint:errcheck
}
}
}
// markSynced resolves any open drift events for envID and updates the config.
func markSynced(db *xorm.Engine, envID int64, sha string) {
now := time.Now().UTC()
db.Where("env_id = ? AND resolved_at IS NULL", envID).
Cols("sync_status", "resolved_at").
Update(&models.GitOpsDriftEvent{SyncStatus: "synced", ResolvedAt: &now}) //nolint:errcheck
db.Where("env_id = ?", envID).
Cols("sync_status", "actual_sha", "last_checked_at").
Update(&models.GitOpsConfig{SyncStatus: "synced", ActualSHA: sha, LastCheckedAt: &now}) //nolint:errcheck
}
func lastChecked(cfg models.GitOpsConfig) time.Time {
if cfg.LastCheckedAt != nil {
return *cfg.LastCheckedAt
}
return cfg.CreatedAt
}
func sha7(s string) string {
if len(s) >= 7 {
return s[:7]
}
if s == "" {
return "(none)"
}
return s
}
+97
View File
@@ -0,0 +1,97 @@
package gitops
import (
"encoding/json"
"log"
"time"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// TriggerSync creates a Deployment record in "pending" state and fires
// deployment.started — the same path as a manually-triggered deployment.
// GitOps is just the trigger; actual execution is handled externally (or via CI).
func (c *Controller) TriggerSync(cfg models.GitOpsConfig, desiredSHA string) {
var env models.Environment
if found, _ := c.db.ID(cfg.EnvID).Get(&env); !found {
log.Printf("gitops: sync env %d not found", cfg.EnvID)
return
}
now := time.Now().UTC()
deploy := &models.Deployment{
EnvID: cfg.EnvID,
RepoID: cfg.RepoID,
SHA: desiredSHA,
Ref: "refs/heads/" + cfg.Branch,
Status: models.DeployStatusPending,
TriggeredBy: "gitops",
Description: "GitOps auto-sync",
StartedAt: &now,
}
if _, err := c.db.Insert(deploy); err != nil {
log.Printf("gitops: create deployment: %v", err)
return
}
cfg.SyncStatus = "syncing"
c.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
c.bus.Publish(events.SubjectDeploymentStarted, events.DeploymentEvent{ //nolint:errcheck
DeploymentID: deploy.ID,
EnvID: env.ID,
EnvName: env.Name,
RepoID: deploy.RepoID,
SHA: deploy.SHA,
Ref: deploy.Ref,
Status: string(deploy.Status),
TriggeredBy: deploy.TriggeredBy,
})
log.Printf("gitops: triggered sync deploy %d for env %d (%s)", deploy.ID, cfg.EnvID, desiredSHA[:7])
}
// handleDeploymentSucceeded is called when any deployment.succeeded event fires.
// If the deployment was GitOps-triggered, it marks the config as synced.
func (c *Controller) handleDeploymentSucceeded(data []byte) {
var evt events.DeploymentEvent
if err := json.Unmarshal(data, &evt); err != nil {
return
}
// Only act on deployments triggered by gitops.
if evt.TriggeredBy != "gitops" {
// Still update ActualSHA and resolve drift if this env has a GitOps config —
// manual deployments also advance the state.
var cfg models.GitOpsConfig
if found, _ := c.db.Where("env_id = ?", evt.EnvID).Get(&cfg); found {
markSynced(c.db, evt.EnvID, evt.SHA)
log.Printf("gitops: env %d synced via manual deploy (%s)", evt.EnvID, sha7(evt.SHA))
}
return
}
markSynced(c.db, evt.EnvID, evt.SHA)
log.Printf("gitops: env %d synced (%s)", evt.EnvID, sha7(evt.SHA))
}
// handleDeploymentFailed is called when deployment.failed fires.
// If the deployment was GitOps-triggered, it reverts SyncStatus back to drifted.
func (c *Controller) handleDeploymentFailed(data []byte) {
var evt events.DeploymentEvent
if err := json.Unmarshal(data, &evt); err != nil {
return
}
if evt.TriggeredBy != "gitops" {
return
}
var cfg models.GitOpsConfig
if found, _ := c.db.Where("env_id = ?", evt.EnvID).Get(&cfg); !found {
return
}
cfg.SyncStatus = "drifted"
c.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
log.Printf("gitops: env %d sync failed — reverting to drifted", evt.EnvID)
}
+375
View File
@@ -0,0 +1,375 @@
// Package oci implements an OCI Distribution Specification v1.1 registry.
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md
//
// Storage layout under ociRoot:
//
// blobs/sha256/<hex64> — content-addressable layer/config blobs
// uploads/<uuid> — temporary files for in-progress chunked uploads
package oci
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Registry manages the on-disk blob store and is used by the HTTP handler.
type Registry struct {
root string // absolute path to the OCI storage root
}
// New creates a Registry rooted at ociRoot, creating the directory tree if needed.
func New(ociRoot string) (*Registry, error) {
for _, sub := range []string{"blobs/sha256", "uploads"} {
if err := os.MkdirAll(filepath.Join(ociRoot, sub), 0700); err != nil {
return nil, fmt.Errorf("oci: init storage %s: %w", sub, err)
}
}
return &Registry{root: ociRoot}, nil
}
// Root returns the storage root path.
func (r *Registry) Root() string { return r.root }
// ─── Blob paths ───────────────────────────────────────────────────────────────
// BlobPath returns the filesystem path for a blob identified by its digest.
// digest must be in the form "sha256:<hex>".
func (r *Registry) BlobPath(digest string) (string, error) {
hex, err := digestHex(digest)
if err != nil {
return "", err
}
return filepath.Join(r.root, "blobs", "sha256", hex), nil
}
// UploadPath returns the filesystem path for a chunked upload session.
func (r *Registry) UploadPath(uploadID string) string {
return filepath.Join(r.root, "uploads", sanitiseID(uploadID))
}
// BlobExists reports whether a blob with the given digest exists on disk.
func (r *Registry) BlobExists(digest string) bool {
p, err := r.BlobPath(digest)
if err != nil {
return false
}
_, err = os.Stat(p)
return err == nil
}
// BlobSize returns the size of the blob in bytes, or -1 if it doesn't exist.
func (r *Registry) BlobSize(digest string) int64 {
p, err := r.BlobPath(digest)
if err != nil {
return -1
}
info, err := os.Stat(p)
if err != nil {
return -1
}
return info.Size()
}
// ReadBlob opens a blob for streaming. Caller must close the returned file.
func (r *Registry) ReadBlob(digest string) (*os.File, error) {
p, err := r.BlobPath(digest)
if err != nil {
return nil, err
}
return os.Open(p)
}
// WriteBlob writes src into the blob store, verifies the digest, and returns
// the computed digest string ("sha256:<hex>") and size.
// If a blob with the same digest already exists it is not overwritten.
func (r *Registry) WriteBlob(src io.Reader) (digest string, size int64, err error) {
tmp, err := os.CreateTemp(filepath.Join(r.root, "uploads"), "blob-*")
if err != nil {
return "", 0, fmt.Errorf("oci: create tmp blob: %w", err)
}
tmpPath := tmp.Name()
defer func() {
tmp.Close()
if err != nil {
os.Remove(tmpPath)
}
}()
h := sha256.New()
mw := io.MultiWriter(tmp, h)
size, err = io.Copy(mw, src)
if err != nil {
return "", 0, fmt.Errorf("oci: write blob: %w", err)
}
tmp.Close()
digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
dest, err2 := r.BlobPath(digest)
if err2 != nil {
os.Remove(tmpPath)
return "", 0, err2
}
if _, statErr := os.Stat(dest); statErr == nil {
// Already exists — deduplication.
os.Remove(tmpPath)
return digest, size, nil
}
if err = os.Rename(tmpPath, dest); err != nil {
return "", 0, fmt.Errorf("oci: commit blob: %w", err)
}
return digest, size, nil
}
// FinishUpload finalises a chunked upload: reads the temp file, verifies
// clientDigest (if non-empty), atomically moves it to the blob store, and
// returns the canonical digest and size.
func (r *Registry) FinishUpload(uploadID, clientDigest string) (digest string, size int64, err error) {
src := r.UploadPath(uploadID)
f, err := os.Open(src)
if err != nil {
return "", 0, fmt.Errorf("oci: open upload: %w", err)
}
h := sha256.New()
size, err = io.Copy(h, f)
f.Close()
if err != nil {
return "", 0, fmt.Errorf("oci: hash upload: %w", err)
}
digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
if clientDigest != "" && clientDigest != digest {
os.Remove(src)
return "", 0, &DigestMismatch{Expected: clientDigest, Actual: digest}
}
dest, err := r.BlobPath(digest)
if err != nil {
return "", 0, err
}
if _, statErr := os.Stat(dest); statErr == nil {
// Blob already exists — dedup.
os.Remove(src)
return digest, size, nil
}
if err = os.Rename(src, dest); err != nil {
return "", 0, fmt.Errorf("oci: commit upload: %w", err)
}
return digest, size, nil
}
// AppendUpload appends src to an existing upload session file and returns the
// new total offset.
func (r *Registry) AppendUpload(uploadID string, src io.Reader) (newOffset int64, err error) {
path := r.UploadPath(uploadID)
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return 0, fmt.Errorf("oci: open upload for append: %w", err)
}
defer f.Close()
n, err := io.Copy(f, src)
if err != nil {
return 0, fmt.Errorf("oci: append upload: %w", err)
}
info, err := f.Stat()
if err != nil {
return n, nil
}
return info.Size(), nil
}
// UploadOffset returns the number of bytes written to an upload session so far.
func (r *Registry) UploadOffset(uploadID string) int64 {
info, err := os.Stat(r.UploadPath(uploadID))
if err != nil {
return 0
}
return info.Size()
}
// CancelUpload removes the temporary upload file.
func (r *Registry) CancelUpload(uploadID string) {
os.Remove(r.UploadPath(uploadID))
}
// DeleteBlob removes a blob from disk.
func (r *Registry) DeleteBlob(digest string) error {
p, err := r.BlobPath(digest)
if err != nil {
return err
}
return os.Remove(p)
}
// ─── Manifest helpers ─────────────────────────────────────────────────────────
// ManifestDescriptor extracts the digest and size from a raw manifest body.
func ManifestDescriptor(body []byte) (digest string, size int64) {
h := sha256.Sum256(body)
return "sha256:" + hex.EncodeToString(h[:]), int64(len(body))
}
// IsDigestRef returns true when ref looks like a digest ("sha256:<hex>").
func IsDigestRef(ref string) bool {
return strings.HasPrefix(ref, "sha256:")
}
// ─── OCI error types ─────────────────────────────────────────────────────────
// ErrorCode is an OCI Distribution API error code.
type ErrorCode string
const (
ErrBlobUnknown ErrorCode = "BLOB_UNKNOWN"
ErrBlobUploadInvalid ErrorCode = "BLOB_UPLOAD_INVALID"
ErrBlobUploadUnknown ErrorCode = "BLOB_UPLOAD_UNKNOWN"
ErrDigestInvalid ErrorCode = "DIGEST_INVALID"
ErrManifestBlobUnknown ErrorCode = "MANIFEST_BLOB_UNKNOWN"
ErrManifestInvalid ErrorCode = "MANIFEST_INVALID"
ErrManifestUnknown ErrorCode = "MANIFEST_UNKNOWN"
ErrNameInvalid ErrorCode = "NAME_INVALID"
ErrNameUnknown ErrorCode = "NAME_UNKNOWN"
ErrTagInvalid ErrorCode = "TAG_INVALID"
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
ErrDenied ErrorCode = "DENIED"
ErrUnsupported ErrorCode = "UNSUPPORTED"
)
// APIError is a single OCI error entry.
type APIError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Detail interface{} `json:"detail,omitempty"`
}
// ErrorResponse is the top-level OCI error response body.
type ErrorResponse struct {
Errors []APIError `json:"errors"`
}
// NewError builds an ErrorResponse JSON body.
func NewError(code ErrorCode, msg string) []byte {
b, _ := json.Marshal(ErrorResponse{Errors: []APIError{{Code: code, Message: msg}}})
return b
}
// DigestMismatch is returned when a client-provided digest doesn't match the computed one.
type DigestMismatch struct {
Expected string
Actual string
}
func (e *DigestMismatch) Error() string {
return fmt.Sprintf("digest mismatch: expected %s, got %s", e.Expected, e.Actual)
}
// ─── path helpers ─────────────────────────────────────────────────────────────
// digestHex validates a "sha256:<hex>" digest string and returns the hex part.
func digestHex(digest string) (string, error) {
if !strings.HasPrefix(digest, "sha256:") {
return "", fmt.Errorf("oci: only sha256 digests are supported, got %q", digest)
}
h := strings.TrimPrefix(digest, "sha256:")
if len(h) != 64 {
return "", fmt.Errorf("oci: invalid sha256 digest length: %d", len(h))
}
return h, nil
}
// sanitiseID returns only the last path component of an upload ID,
// preventing any path traversal regardless of encoding.
func sanitiseID(id string) string {
return filepath.Base(id)
}
// ParseOCIPath extracts the image name and the operation kind from a path
// under /v2/. name may contain slashes (e.g. "alice/myapp").
//
// Returns: name, kind, ref where kind is one of:
//
// "tags" → ref = ""
// "manifest" → ref = tag or digest
// "blob" → ref = digest
// "upload" → ref = uploadID (empty for new upload)
// "" → unrecognised path
func ParseOCIPath(rawPath string) (name, kind, ref string) {
// Strip leading /v2/
p := strings.TrimPrefix(rawPath, "/v2/")
if p == "" || p == "/" {
return "", "", ""
}
// Try known suffixes from most to least specific.
type suffix struct {
needle string
kind string
}
suffixes := []suffix{
{"/blobs/uploads/", "upload"},
{"/blobs/sha256:", "blob"},
{"/blobs/", "blob"},
{"/manifests/", "manifest"},
{"/tags/list", "tags"},
}
for _, s := range suffixes {
idx := strings.Index(p, s.needle)
if idx < 0 {
continue
}
name = p[:idx]
rest := p[idx+len(s.needle):]
kind = s.kind
switch s.kind {
case "blob":
// ref is digest: re-attach the sha256: prefix if needed
if strings.HasSuffix(s.needle, ":") {
ref = "sha256:" + rest
} else {
ref = rest
}
case "upload":
ref = rest // upload UUID or empty for new session
default:
ref = rest
}
return name, kind, ref
}
return "", "", ""
}
// ValidateName returns an error if the image name is empty or contains
// invalid characters.
func ValidateName(name string) error {
if name == "" {
return errors.New("empty image name")
}
for _, c := range name {
if !isNameChar(c) {
return fmt.Errorf("invalid character %q in image name", c)
}
}
return nil
}
func isNameChar(c rune) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '.' || c == '-' || c == '_' || c == '/'
}
+254
View File
@@ -0,0 +1,254 @@
package oci_test
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/forgeo/forgebucket/internal/domain/oci"
)
func TestParseOCIPath(t *testing.T) {
tests := []struct {
path string
wantName string
wantKind string
wantRef string
}{
{"/v2/", "", "", ""},
{"/v2", "", "", ""},
{"/v2/alice/myapp/tags/list", "alice/myapp", "tags", ""},
{"/v2/alice/myapp/manifests/latest", "alice/myapp", "manifest", "latest"},
{"/v2/alice/myapp/manifests/sha256:abc123", "alice/myapp", "manifest", "sha256:abc123"},
{"/v2/alice/myapp/blobs/sha256:def456", "alice/myapp", "blob", "sha256:def456"},
{"/v2/alice/myapp/blobs/uploads/", "alice/myapp", "upload", ""},
{"/v2/alice/myapp/blobs/uploads/uuid123", "alice/myapp", "upload", "uuid123"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name, kind, ref := oci.ParseOCIPath(tt.path)
if name != tt.wantName {
t.Errorf("name = %q, want %q", name, tt.wantName)
}
if kind != tt.wantKind {
t.Errorf("kind = %q, want %q", kind, tt.wantKind)
}
if ref != tt.wantRef {
t.Errorf("ref = %q, want %q", ref, tt.wantRef)
}
})
}
}
func TestValidateName(t *testing.T) {
if err := oci.ValidateName("alice/myapp"); err != nil {
t.Errorf("valid name got error: %v", err)
}
if err := oci.ValidateName(""); err == nil {
t.Error("empty name should error")
}
if err := oci.ValidateName("alice/my app"); err == nil {
t.Error("name with spaces should error")
}
}
func TestBlobPath(t *testing.T) {
dir := t.TempDir()
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
p, err := reg.BlobPath("sha256:" + strings.Repeat("a", 64))
if err != nil {
t.Fatal(err)
}
expectedSuffix := filepath.Join("blobs", "sha256", strings.Repeat("a", 64))
if !strings.HasSuffix(p, expectedSuffix) {
t.Errorf("path %q does not end with %q", p, expectedSuffix)
}
if _, err := reg.BlobPath("sha256:bad"); err == nil {
t.Error("expected error for short hex")
}
if _, err := reg.BlobPath("md5:abc"); err == nil {
t.Error("expected error for non-sha256 algorithm")
}
}
func TestWriteAndReadBlob(t *testing.T) {
dir := t.TempDir()
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
content := []byte("hello oci blob")
digest, size, err := reg.WriteBlob(bytes.NewReader(content))
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("digest should start with sha256:, got %s", digest)
}
if size != int64(len(content)) {
t.Errorf("size = %d, want %d", size, len(content))
}
if !reg.BlobExists(digest) {
t.Error("blob should exist after write")
}
// Deduplication test: writing same content again should succeed without error.
d2, s2, err := reg.WriteBlob(bytes.NewReader(content))
if err != nil {
t.Fatalf("WriteBlob duplicate: %v", err)
}
if d2 != digest {
t.Errorf("digest mismatch: %s vs %s", d2, digest)
}
if s2 != size {
t.Errorf("size mismatch: %d vs %d", s2, size)
}
f, err := reg.ReadBlob(digest)
if err != nil {
t.Fatalf("ReadBlob: %v", err)
}
defer f.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(f)
if buf.String() != string(content) {
t.Errorf("content mismatch: got %s", buf.String())
}
}
func TestUploadSession(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
uploadID := "test-upload-001"
// Append content in chunks.
off, err := reg.AppendUpload(uploadID, strings.NewReader("chunk1"))
if err != nil {
t.Fatalf("AppendUpload: %v", err)
}
if off != 6 {
t.Errorf("expected offset 6, got %d", off)
}
off, err = reg.AppendUpload(uploadID, strings.NewReader("-chunk2"))
if err != nil {
t.Fatalf("AppendUpload second: %v", err)
}
if off != 13 {
t.Errorf("expected offset 13 after chunk2, got %d", off)
}
if reg.UploadOffset(uploadID) != 13 {
t.Errorf("UploadOffset = %d, want 13", reg.UploadOffset(uploadID))
}
// Finish upload with digest.
digest, size, err := reg.FinishUpload(uploadID, "")
if err != nil {
t.Fatalf("FinishUpload: %v", err)
}
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("expected sha256 digest, got %s", digest)
}
if size != 13 {
t.Errorf("expected size 13, got %d", size)
}
if !reg.BlobExists(digest) {
t.Error("blob should exist after finish upload")
}
// Verify content.
f, _ := reg.ReadBlob(digest)
buf := new(bytes.Buffer)
buf.ReadFrom(f)
f.Close()
if buf.String() != "chunk1-chunk2" {
t.Errorf("content = %q, want %q", buf.String(), "chunk1-chunk2")
}
}
func TestFinishUploadDigestMismatch(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
uploadID := "mismatch-upload"
reg.AppendUpload(uploadID, strings.NewReader("some data"))
_, _, err := reg.FinishUpload(uploadID, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
if err == nil {
t.Fatal("expected digest mismatch error")
}
if !strings.Contains(err.Error(), "digest mismatch") {
t.Errorf("expected 'digest mismatch', got: %v", err)
}
}
func TestManifestDescriptor(t *testing.T) {
body := []byte(`{"schemaVersion":2}`)
digest, size := oci.ManifestDescriptor(body)
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("digest should be sha256, got %s", digest)
}
if size != int64(len(body)) {
t.Errorf("size = %d, want %d", size, len(body))
}
}
func TestIsDigestRef(t *testing.T) {
if !oci.IsDigestRef("sha256:abc") {
t.Error("sha256:abc should be a digest ref")
}
if oci.IsDigestRef("latest") {
t.Error("latest should NOT be a digest ref")
}
}
func TestDeleteBlob(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
content := []byte("delete me")
digest, _, _ := reg.WriteBlob(bytes.NewReader(content))
if !reg.BlobExists(digest) {
t.Fatal("blob should exist after write")
}
if err := reg.DeleteBlob(digest); err != nil {
t.Fatalf("DeleteBlob: %v", err)
}
if reg.BlobExists(digest) {
t.Error("blob should not exist after delete")
}
}
func TestNewCreatesDirectories(t *testing.T) {
dir := filepath.Join(t.TempDir(), "oci-storage")
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
for _, sub := range []string{"blobs/sha256", "uploads"} {
p := filepath.Join(dir, sub)
if _, err := os.Stat(p); os.IsNotExist(err) {
t.Errorf("directory not created: %s", p)
}
}
_ = reg
}
+99
View File
@@ -0,0 +1,99 @@
// Package sbom generates Software Bills of Materials in CycloneDX 1.4 JSON format.
// https://cyclonedx.org/specification/overview/
package sbom
import (
"fmt"
"time"
)
const (
FormatCycloneDX = "cyclonedx-json-1.4"
SpecVersion = "1.4"
BOMFormat = "CycloneDX"
)
// Document is the top-level CycloneDX 1.4 BOM.
type Document struct {
BOMFormat string `json:"bomFormat"`
SpecVersion string `json:"specVersion"`
SerialNumber string `json:"serialNumber"`
Version int `json:"version"`
Metadata Metadata `json:"metadata"`
Components []Component `json:"components"`
}
type Metadata struct {
Timestamp string `json:"timestamp"`
Tools []Tool `json:"tools"`
Component *Component `json:"component,omitempty"`
}
type Tool struct {
Vendor string `json:"vendor"`
Name string `json:"name"`
Version string `json:"version"`
}
// Component represents a software dependency in the BOM.
type Component struct {
Type string `json:"type"` // "library", "application", "framework"
Name string `json:"name"`
Version string `json:"version,omitempty"`
PURL string `json:"purl,omitempty"`
Description string `json:"description,omitempty"`
Scope string `json:"scope,omitempty"` // "required", "optional"
ExternalRefs []ExternalRef `json:"externalReferences,omitempty"`
}
type ExternalRef struct {
Type string `json:"type"` // "website", "vcs", "distribution"
URL string `json:"url"`
}
// NewDocument creates a blank CycloneDX 1.4 document with metadata populated.
func NewDocument(repoName, sha string) *Document {
return &Document{
BOMFormat: BOMFormat,
SpecVersion: SpecVersion,
SerialNumber: fmt.Sprintf("urn:uuid:forgebucket:%s:%s", repoName, sha[:7]),
Version: 1,
Metadata: Metadata{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Tools: []Tool{
{Vendor: "ForgeBucket", Name: "sbom-generator", Version: "1.0.0"},
},
Component: &Component{
Type: "application",
Name: repoName,
},
},
Components: []Component{},
}
}
// PURL helpers — produce Package URL strings per ecosystem.
func golangPURL(module, version string) string {
return fmt.Sprintf("pkg:golang/%s@%s", module, version)
}
func npmPURL(name, version string) string {
return fmt.Sprintf("pkg:npm/%s@%s", name, version)
}
func pypiPURL(name, version string) string {
return fmt.Sprintf("pkg:pypi/%s@%s", name, version)
}
func cargoPURL(name, version string) string {
return fmt.Sprintf("pkg:cargo/%s@%s", name, version)
}
func gemPURL(name, version string) string {
return fmt.Sprintf("pkg:gem/%s@%s", name, version)
}
func mavenPURL(group, artifact, version string) string {
return fmt.Sprintf("pkg:maven/%s/%s@%s", group, artifact, version)
}
+204
View File
@@ -0,0 +1,204 @@
package sbom
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/models"
)
// manifestEntry maps a known manifest file path to its parser function.
type manifestEntry struct {
path string
parser func([]byte) []Component
}
// knownManifests is the ordered list of manifest files the generator probes.
// Files are tried in order; all that exist at the given SHA are parsed.
var knownManifests = []manifestEntry{
{"go.mod", ParseGoMod},
{"package.json", ParsePackageJSON},
{"requirements.txt", ParseRequirementsTxt},
{"Cargo.toml", ParseCargoToml},
{"Gemfile.lock", ParseGemfileLock},
{"pom.xml", ParsePomXML},
}
// Generator subscribes to pipeline.completed events and produces SBOM reports.
type Generator struct {
db *xorm.Engine
bus events.EventBus
}
func NewGenerator(db *xorm.Engine, bus events.EventBus) *Generator {
return &Generator{db: db, bus: bus}
}
// Start subscribes to pipeline.completed and blocks until ctx is cancelled.
func (g *Generator) Start(ctx context.Context) {
unsub, err := g.bus.Subscribe(events.SubjectPipelineCompleted, func(_ string, data []byte) {
var evt events.PipelineEvent
if err := json.Unmarshal(data, &evt); err != nil {
log.Printf("sbom: bad pipeline.completed event: %v", err)
return
}
if evt.Status != "succeeded" {
return
}
go g.generateForRun(evt.RunID, evt.RepoID)
})
if err != nil {
log.Printf("sbom: subscribe pipeline.completed: %v", err)
} else {
defer unsub()
}
<-ctx.Done()
}
// generateForRun generates an SBOM for the pipeline run identified by runID.
func (g *Generator) generateForRun(runID, repoID int64) {
var run models.PipelineRun
if found, err := g.db.ID(runID).Get(&run); err != nil {
log.Printf("sbom: look up run %d: %v", runID, err)
return
} else if !found {
return
}
var repo models.Repository
if found, err := g.db.ID(repoID).Get(&repo); err != nil {
log.Printf("sbom: look up repo %d: %v", repoID, err)
return
} else if !found {
return
}
doc, err := Generate(repo.DiskPath, repo.Name, run.TriggerSHA)
if err != nil {
log.Printf("sbom: generate for run %d: %v", runID, err)
return
}
if err := g.persist(repoID, runID, run.TriggerSHA, doc); err != nil {
log.Printf("sbom: persist for run %d: %v", runID, err)
}
}
// GenerateOnDemand generates an SBOM for a specific repo + SHA and stores it
// (or returns the cached one if the SHA was already processed).
func (g *Generator) GenerateOnDemand(repoID, runID int64, ref string) (*models.SBOMReport, error) {
var repo models.Repository
if found, _ := g.db.ID(repoID).Get(&repo); !found {
return nil, fmt.Errorf("repo %d not found", repoID)
}
// Resolve the ref to a full commit SHA — ref can be a branch name, tag, etc.
sha, err := gitdomain.RevParse(repo.DiskPath, ref)
if err != nil {
return nil, fmt.Errorf("rev-parse %s: %w", ref, err)
}
// Return cached report for this exact SHA + runID if one already exists.
// Without runID in the cache key, a prior on-demand generation (runID=0)
// would shadow subsequent per-run generation requests.
var existing models.SBOMReport
if found, _ := g.db.Where("repo_id = ? AND sha = ? AND run_id = ?", repoID, sha, runID).Get(&existing); found {
return &existing, nil
}
doc, err := Generate(repo.DiskPath, repo.Name, sha)
if err != nil {
return nil, err
}
report, err := g.persistAndReturn(repoID, runID, sha, doc)
if err != nil {
return nil, err
}
return report, nil
}
// GetLatest returns the most recent SBOM report for a repo.
func (g *Generator) GetLatest(repoID int64) (*models.SBOMReport, error) {
var report models.SBOMReport
found, err := g.db.Where("repo_id = ?", repoID).
OrderBy("generated_at DESC").
Get(&report)
if err != nil {
return nil, err
}
if !found {
return nil, nil
}
return &report, nil
}
// GetForRun returns the SBOM report associated with a pipeline run.
func (g *Generator) GetForRun(runID int64) (*models.SBOMReport, error) {
var report models.SBOMReport
found, err := g.db.Where("run_id = ?", runID).Get(&report)
if err != nil {
return nil, err
}
if !found {
return nil, nil
}
return &report, nil
}
// ─── core generation logic ────────────────────────────────────────────────────
// Generate reads known manifest files from the git repo at sha and builds
// a CycloneDX 1.4 document. It is safe to call even if no manifests exist
// (the document will have an empty components list).
func Generate(repoPath, repoName, sha string) (*Document, error) {
doc := NewDocument(repoName, sha)
for _, m := range knownManifests {
content, err := gitdomain.BlobCat(repoPath, sha, m.path)
if err != nil {
// File simply doesn't exist at this SHA — skip silently.
continue
}
comps := m.parser(content)
doc.Components = append(doc.Components, comps...)
}
return doc, nil
}
// ─── persistence helpers ──────────────────────────────────────────────────────
func (g *Generator) persist(repoID, runID int64, sha string, doc *Document) error {
_, err := g.persistAndReturn(repoID, runID, sha, doc)
return err
}
func (g *Generator) persistAndReturn(repoID, runID int64, sha string, doc *Document) (*models.SBOMReport, error) {
bomJSON, err := json.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("marshal BOM: %w", err)
}
report := &models.SBOMReport{
RepoID: repoID,
RunID: runID,
SHA: sha,
Format: FormatCycloneDX,
ComponentCount: len(doc.Components),
BOMDocument: string(bomJSON),
GeneratedAt: time.Now().UTC(),
}
if _, err := g.db.Insert(report); err != nil {
return nil, fmt.Errorf("insert sbom_report: %w", err)
}
log.Printf("sbom: generated report %d for repo %d @ %s (%d components)",
report.ID, repoID, sha[:7], report.ComponentCount)
return report, nil
}
+353
View File
@@ -0,0 +1,353 @@
package sbom
import (
"bufio"
"bytes"
"encoding/json"
"strings"
)
// ParseResult holds components extracted from a single manifest file.
type ParseResult struct {
Ecosystem string
Components []Component
}
// ─── go.mod ──────────────────────────────────────────────────────────────────
// ParseGoMod parses a go.mod file and returns Go module components.
// Handles both single-line `require x v1` and block `require ( ... )` forms.
func ParseGoMod(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inBlock := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Strip inline comments.
if idx := strings.Index(line, "//"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
if line == "" {
continue
}
if line == "require (" {
inBlock = true
continue
}
if inBlock && line == ")" {
inBlock = false
continue
}
var modulePath, version string
if inBlock {
parts := strings.Fields(line)
if len(parts) >= 2 {
modulePath, version = parts[0], parts[1]
}
} else if strings.HasPrefix(line, "require ") {
parts := strings.Fields(strings.TrimPrefix(line, "require "))
if len(parts) >= 2 {
modulePath, version = parts[0], parts[1]
}
}
if modulePath == "" {
continue
}
// Indirect deps are still included — they are part of the supply chain.
components = append(components, Component{
Type: "library",
Name: modulePath,
Version: version,
PURL: golangPURL(modulePath, version),
})
}
return components
}
// ─── package.json ─────────────────────────────────────────────────────────────
type packageJSON struct {
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
PeerDependencies map[string]string `json:"peerDependencies"`
}
// ParsePackageJSON parses a package.json and returns npm components.
func ParsePackageJSON(content []byte) []Component {
var pkg packageJSON
if err := json.Unmarshal(content, &pkg); err != nil {
return nil
}
seen := make(map[string]bool)
var components []Component
add := func(name, version, scope string) {
if seen[name] {
return
}
seen[name] = true
// Strip semver range prefixes: ^, ~, >=, >, <=, <, =
clean := strings.TrimLeft(version, "^~>=<")
components = append(components, Component{
Type: "library",
Name: name,
Version: clean,
PURL: npmPURL(name, clean),
Scope: scope,
})
}
for name, ver := range pkg.Dependencies {
add(name, ver, "required")
}
for name, ver := range pkg.DevDependencies {
add(name, ver, "optional")
}
for name, ver := range pkg.PeerDependencies {
add(name, ver, "optional")
}
return components
}
// ─── requirements.txt ────────────────────────────────────────────────────────
// ParseRequirementsTxt parses a pip requirements.txt.
// Handles: pkg==1.0, pkg>=1.0, pkg~=1.0, pkg (no version), comments, extras.
func ParseRequirementsTxt(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
continue
}
// Strip inline comments.
if idx := strings.Index(line, " #"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
// Strip extras: package[extra]==1.0 → package, ==1.0
name := line
version := ""
for _, op := range []string{"==", ">=", "<=", "~=", "!=", ">", "<"} {
if idx := strings.Index(line, op); idx >= 0 {
name = strings.TrimSpace(line[:idx])
version = strings.TrimSpace(line[idx+len(op):])
// Take only the first version specifier.
if commaIdx := strings.Index(version, ","); commaIdx >= 0 {
version = version[:commaIdx]
}
break
}
}
// Strip extras [extra1,extra2] from name.
if bIdx := strings.Index(name, "["); bIdx >= 0 {
name = name[:bIdx]
}
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
continue
}
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: pypiPURL(name, version),
})
}
return components
}
// ─── Cargo.toml ──────────────────────────────────────────────────────────────
// ParseCargoToml parses a Cargo.toml [dependencies] section.
// Handles: name = "version" and name = { version = "x", ... }.
func ParseCargoToml(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inDeps := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#") {
continue
}
// Section headers.
if strings.HasPrefix(line, "[") {
inDeps = line == "[dependencies]" ||
line == "[dev-dependencies]" ||
line == "[build-dependencies]"
continue
}
if !inDeps || line == "" {
continue
}
eqIdx := strings.Index(line, "=")
if eqIdx < 0 {
continue
}
name := strings.TrimSpace(line[:eqIdx])
rest := strings.TrimSpace(line[eqIdx+1:])
var version string
if strings.HasPrefix(rest, `"`) {
// name = "version"
version = strings.Trim(rest, `"`)
} else if strings.HasPrefix(rest, "{") {
// name = { version = "x", features = [...] }
if vIdx := strings.Index(rest, `version = "`); vIdx >= 0 {
vIdx += len(`version = "`)
endIdx := strings.Index(rest[vIdx:], `"`)
if endIdx >= 0 {
version = rest[vIdx : vIdx+endIdx]
}
}
}
if name == "" {
continue
}
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: cargoPURL(name, version),
})
}
return components
}
// ─── Gemfile.lock ─────────────────────────────────────────────────────────────
// ParseGemfileLock parses a Gemfile.lock and extracts gem components.
// The GEM section format is:
//
// GEM
// remote: https://rubygems.org/
// specs:
// activesupport (7.1.0)
// ...
func ParseGemfileLock(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inSpecs := false
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "GEM" {
continue
}
if trimmed == "specs:" {
inSpecs = true
continue
}
// Any non-indented non-empty line ends the specs block.
if inSpecs && !strings.HasPrefix(line, " ") && trimmed != "" {
inSpecs = false
}
if !inSpecs {
continue
}
// Specs entries are indented exactly 4 spaces: " name (version)"
// Sub-dependencies are indented 6+ spaces — skip them.
if !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
continue
}
// Parse: " gemname (version)"
entry := strings.TrimSpace(line)
oIdx := strings.Index(entry, " (")
if oIdx < 0 {
continue
}
name := entry[:oIdx]
versionFull := strings.TrimSuffix(entry[oIdx+2:], ")")
version := strings.Fields(versionFull)[0]
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: gemPURL(name, version),
})
}
return components
}
// ─── pom.xml (minimal) ───────────────────────────────────────────────────────
// ParsePomXML does a lightweight line-scan extraction of Maven dependencies.
// It avoids pulling in an XML parser — it looks for <dependency> blocks and
// extracts groupId, artifactId, version tags.
func ParsePomXML(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
var groupID, artifactID, version string
inDep := false
extract := func(line, tag string) string {
open := "<" + tag + ">"
close := "</" + tag + ">"
sIdx := strings.Index(line, open)
eIdx := strings.Index(line, close)
if sIdx >= 0 && eIdx > sIdx {
return line[sIdx+len(open) : eIdx]
}
return ""
}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "<dependency>") {
inDep = true
groupID, artifactID, version = "", "", ""
continue
}
if strings.Contains(line, "</dependency>") {
if inDep && groupID != "" && artifactID != "" {
name := groupID + ":" + artifactID
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: mavenPURL(groupID, artifactID, version),
})
}
inDep = false
continue
}
if !inDep {
continue
}
if v := extract(line, "groupId"); v != "" {
groupID = v
}
if v := extract(line, "artifactId"); v != "" {
artifactID = v
}
if v := extract(line, "version"); v != "" {
version = v
}
}
return components
}
+293
View File
@@ -0,0 +1,293 @@
package sbom_test
import (
"testing"
"github.com/forgeo/forgebucket/internal/domain/sbom"
)
// ─── go.mod ──────────────────────────────────────────────────────────────────
func TestParseGoMod_Block(t *testing.T) {
content := []byte(`module github.com/example/app
go 1.21
require (
github.com/go-chi/chi/v5 v5.2.5
golang.org/x/crypto v0.50.0 // indirect
)
require github.com/lib/pq v1.12.3
`)
comps := sbom.ParseGoMod(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["github.com/go-chi/chi/v5"]; !ok {
t.Error("missing github.com/go-chi/chi/v5")
} else {
if c.Version != "v5.2.5" {
t.Errorf("wrong version: %s", c.Version)
}
if c.PURL != "pkg:golang/github.com/go-chi/chi/v5@v5.2.5" {
t.Errorf("wrong PURL: %s", c.PURL)
}
}
if _, ok := byName["golang.org/x/crypto"]; !ok {
t.Error("missing golang.org/x/crypto (indirect deps must be included)")
}
if _, ok := byName["github.com/lib/pq"]; !ok {
t.Error("missing github.com/lib/pq (single-line require)")
}
}
func TestParseGoMod_Empty(t *testing.T) {
comps := sbom.ParseGoMod([]byte("module foo\n\ngo 1.21\n"))
if len(comps) != 0 {
t.Errorf("expected 0 components, got %d", len(comps))
}
}
// ─── package.json ─────────────────────────────────────────────────────────────
func TestParsePackageJSON(t *testing.T) {
content := []byte(`{
"name": "my-app",
"dependencies": {
"react": "^18.2.0",
"axios": "1.4.0"
},
"devDependencies": {
"vitest": "~1.0.0"
}
}`)
comps := sbom.ParsePackageJSON(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["react"]; !ok {
t.Error("missing react")
} else {
if c.Version != "18.2.0" {
t.Errorf("expected version stripped of ^, got %s", c.Version)
}
if c.PURL != "pkg:npm/react@18.2.0" {
t.Errorf("wrong PURL: %s", c.PURL)
}
if c.Scope != "required" {
t.Errorf("expected scope required, got %s", c.Scope)
}
}
if c, ok := byName["vitest"]; !ok {
t.Error("missing vitest")
} else if c.Scope != "optional" {
t.Errorf("devDependency should be optional, got %s", c.Scope)
}
}
func TestParsePackageJSON_Invalid(t *testing.T) {
comps := sbom.ParsePackageJSON([]byte("not json"))
if len(comps) != 0 {
t.Errorf("expected 0 on invalid JSON, got %d", len(comps))
}
}
// ─── requirements.txt ────────────────────────────────────────────────────────
func TestParseRequirementsTxt(t *testing.T) {
content := []byte(`# comment
requests==2.31.0
flask>=2.3.0
numpy~=1.24.0
boto3[s3]==1.28.0 # with extras
no-version-package
-r other-requirements.txt
`)
comps := sbom.ParseRequirementsTxt(content)
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["requests"]; !ok {
t.Error("missing requests")
} else if c.Version != "2.31.0" {
t.Errorf("requests version: %s", c.Version)
}
if c, ok := byName["flask"]; !ok {
t.Error("missing flask")
} else if c.Version != "2.3.0" {
t.Errorf("flask version: %s", c.Version)
}
if _, ok := byName["boto3"]; !ok {
t.Error("missing boto3 (extras should be stripped from name)")
}
if _, ok := byName["no-version-package"]; !ok {
t.Error("missing no-version-package")
}
}
// ─── Cargo.toml ──────────────────────────────────────────────────────────────
func TestParseCargoToml(t *testing.T) {
content := []byte(`[package]
name = "my-crate"
version = "0.1.0"
[dependencies]
serde = "1.0"
tokio = { version = "1.28", features = ["full"] }
clap = "4.3"
[dev-dependencies]
criterion = "0.5"
`)
comps := sbom.ParseCargoToml(content)
if len(comps) != 4 {
t.Fatalf("expected 4 components, got %d: %v", len(comps), comps)
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["serde"]; !ok {
t.Error("missing serde")
} else if c.Version != "1.0" {
t.Errorf("serde version: %s", c.Version)
}
if c, ok := byName["tokio"]; !ok {
t.Error("missing tokio")
} else if c.Version != "1.28" {
t.Errorf("tokio version: %s", c.Version)
}
if _, ok := byName["criterion"]; !ok {
t.Error("missing criterion (dev-dependency)")
}
}
// ─── Gemfile.lock ─────────────────────────────────────────────────────────────
func TestParseGemfileLock(t *testing.T) {
content := []byte(`GEM
remote: https://rubygems.org/
specs:
rails (7.1.0)
actionpack (= 7.1.0)
railties (= 7.1.0)
actionpack (7.1.0)
rake (13.0.6)
PLATFORMS
ruby
DEPENDENCIES
rails (~> 7.1.0)
`)
comps := sbom.ParseGemfileLock(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d: %v", len(comps), comps)
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["rails"]; !ok {
t.Error("missing rails")
} else if c.Version != "7.1.0" {
t.Errorf("rails version: %s", c.Version)
}
if c, ok := byName["rake"]; !ok {
t.Error("missing rake")
} else if c.Version != "13.0.6" {
t.Errorf("rake version: %s", c.Version)
}
}
// ─── pom.xml ─────────────────────────────────────────────────────────────────
func TestParsePomXML(t *testing.T) {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<project>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.6.0</version>
</dependency>
</dependencies>
</project>`)
comps := sbom.ParsePomXML(content)
if len(comps) != 2 {
t.Fatalf("expected 2 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["org.springframework.boot:spring-boot-starter-web"]; !ok {
t.Error("missing spring-boot-starter-web")
} else {
if c.Version != "3.1.0" {
t.Errorf("spring-boot version: %s", c.Version)
}
if c.PURL != "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.1.0" {
t.Errorf("wrong PURL: %s", c.PURL)
}
}
}
// ─── Document builder ─────────────────────────────────────────────────────────
func TestNewDocument(t *testing.T) {
doc := sbom.NewDocument("my-repo", "abc1234567890")
if doc.BOMFormat != "CycloneDX" {
t.Errorf("BOMFormat: %s", doc.BOMFormat)
}
if doc.SpecVersion != "1.4" {
t.Errorf("SpecVersion: %s", doc.SpecVersion)
}
if doc.Metadata.Component.Name != "my-repo" {
t.Errorf("metadata component name: %s", doc.Metadata.Component.Name)
}
if len(doc.Metadata.Tools) == 0 {
t.Error("expected at least one tool in metadata")
}
if doc.Components == nil {
t.Error("expected non-nil Components slice")
}
}
+173
View File
@@ -0,0 +1,173 @@
package scanning
import (
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/models"
)
// compiledPattern is a pre-compiled regex pattern.
type compiledPattern struct {
pattern
re *regexp.Regexp
}
// Scanner subscribes to push.received and scans commit content for secrets.
type Scanner struct {
db *xorm.Engine
bus events.EventBus
patterns []compiledPattern
}
// New creates a Scanner with all patterns pre-compiled.
func New(db *xorm.Engine, bus events.EventBus) (*Scanner, error) {
cp := make([]compiledPattern, 0, len(Patterns))
for _, p := range Patterns {
re, err := regexp.Compile(p.Raw)
if err != nil {
return nil, fmt.Errorf("scanning: compile pattern %q: %w", p.Name, err)
}
cp = append(cp, compiledPattern{pattern: p, re: re})
}
return &Scanner{db: db, bus: bus, patterns: cp}, nil
}
// Start subscribes to push.received and blocks until ctx is cancelled.
func (s *Scanner) Start(ctx context.Context) {
unsub, err := s.bus.Subscribe(events.SubjectPushReceived, func(_ string, data []byte) {
var evt events.PushEvent
if err := json.Unmarshal(data, &evt); err != nil {
log.Printf("scanning: bad push event: %v", err)
return
}
go s.scanPush(evt)
})
if err != nil {
log.Printf("scanning: subscribe: %v", err)
} else {
defer unsub()
}
<-ctx.Done()
}
// scanPush scans the diff between before and after for all patterns.
func (s *Scanner) scanPush(evt events.PushEvent) {
// Branch deletion — nothing to scan.
zeroOID := "0000000000000000000000000000000000000000"
if evt.After == zeroOID {
return
}
// Resolve repo.
var repo models.Repository
if found, err := s.db.ID(evt.RepoID).Get(&repo); err != nil {
log.Printf("scanning: look up repo %d: %v", evt.RepoID, err)
return
} else if !found {
return
}
// Get the diff content between before and after.
diffContent, err := s.getDiff(repo.DiskPath, evt.Before, evt.After)
if err != nil {
log.Printf("scanning: get diff for repo %s: %v", repo.Name, err)
return
}
// Determine the commit SHA for the findings.
headSHA := evt.After
now := time.Now().UTC()
for _, p := range s.patterns {
matches := p.re.FindAllString(string(diffContent), -1)
for _, match := range matches {
// Skip very short matches (likely false positives).
if len(match) < 6 {
continue
}
leak := &models.SecretLeak{
RepoID: evt.RepoID,
CommitSHA: headSHA[:12],
Ref: evt.Ref,
PatternName: p.Name,
Description: p.Description,
Severity: p.Severity,
MatchSample: truncate(match, 40),
DetectedAt: now,
}
if _, err := s.db.Insert(leak); err != nil {
log.Printf("scanning: insert leak for %s: %v", repo.Name, err)
}
}
}
}
// getDiff returns the unified diff of all changes between two refs.
func (s *Scanner) getDiff(repoPath, oldRef, newRef string) ([]byte, error) {
// If oldRef is the zero OID (new branch), diff-tree against the empty tree so
// we get actual file contents rather than ls-tree metadata.
zeroOID := "0000000000000000000000000000000000000000"
if oldRef == zeroOID {
out, err := gitdomain.Run(repoPath, "diff-tree", "--no-commit-id", "-r", "-p", newRef)
if err != nil {
return nil, err
}
return out, nil
}
out, err := gitdomain.Run(repoPath, "diff", "--no-color", "--unified=3", oldRef, newRef)
if err != nil {
return nil, err
}
return out, nil
}
// ListFindings returns all active secret leaks for a repo, newest first.
func (s *Scanner) ListFindings(repoID int64) ([]models.SecretLeak, error) {
var leaks []models.SecretLeak
if err := s.db.Where("repo_id = ? AND dismissed = ?", repoID, false).
OrderBy("detected_at DESC").Find(&leaks); err != nil {
return nil, err
}
if leaks == nil {
leaks = []models.SecretLeak{}
}
return leaks, nil
}
// DismissFindings acknowledges a leak so it no longer appears in active lists.
func (s *Scanner) DismissFindings(leakID int64, dismissedBy string) error {
now := time.Now().UTC()
affected, err := s.db.ID(leakID).Cols("dismissed", "dismissed_by", "dismissed_at").
Update(&models.SecretLeak{
Dismissed: true,
DismissedBy: dismissedBy,
DismissedAt: &now,
})
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("leak %d not found", leakID)
}
return nil
}
// truncate shortens a string to maxLen characters for safe display.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
+106
View File
@@ -0,0 +1,106 @@
package scanning
// pattern holds a compiled regex-like pattern string and its metadata.
// We use raw string patterns rather than importing regexp for each check;
// the Scanner compiles all patterns once at startup.
type pattern struct {
Name string
Description string
Raw string // the regex pattern (compiled at init)
Severity string // "high", "medium", "low"
}
// Patterns is the list of secret patterns checked against every pushed commit.
// Patterns are ordered by severity — high first.
var Patterns = []pattern{
{
Name: "aws-access-key-id",
Description: "AWS Access Key ID",
Raw: `AKIA[0-9A-Z]{16}`,
Severity: "high",
},
{
Name: "aws-secret-key",
Description: "AWS Secret Access Key",
Raw: `(?i)aws[_-]?(secret|private)[_-]?(access[_-]?)?key['"]?\s*[:=]\s*['"]?[A-Za-z0-9\/+=]{40}`,
Severity: "high",
},
{
Name: "github-token",
Description: "GitHub Personal Access Token",
Raw: `gh[pousr]_[A-Za-z0-9_]{36,}`,
Severity: "high",
},
{
Name: "gitlab-token",
Description: "GitLab Personal Access Token",
Raw: `glpat-[A-Za-z0-9\-_]{20,}`,
Severity: "high",
},
{
Name: "generic-api-key",
Description: "Generic API key assignment (high entropy)",
Raw: `(?i)(api[_-]?key|apikey|api[_-]?secret|api[_-]?token)['"]?\s*[:=]\s*['"][A-Za-z0-9_\-\.]{20,64}`,
Severity: "high",
},
{
Name: "bearer-token",
Description: "Bearer token in HTTP header",
Raw: `(?i)authorization:\s*bearer\s+[A-Za-z0-9_\-\.]{20,}`,
Severity: "high",
},
{
Name: "slack-token",
Description: "Slack Bot / Webhook token",
Raw: `xox[baprs]-[A-Za-z0-9\-]{10,}`,
Severity: "high",
},
{
Name: "google-api-key",
Description: "Google API Key",
Raw: `AIza[0-9A-Za-z\-_]{35}`,
Severity: "high",
},
{
Name: "google-service-account",
Description: "Google Service Account",
Raw: `[0-9]+-[0-9a-z]{32}\.apps\.googleusercontent\.com`,
Severity: "high",
},
{
Name: "ssh-private-key",
Description: "SSH / TLS private key embed",
Raw: `-----BEGIN\s+(RSA|EC|OPENSSH|DSA|PRIVATE)(\s+PRIVATE)?\s+KEY-----`,
Severity: "high",
},
{
Name: "jwt-token",
Description: "JSON Web Token (JWT)",
Raw: `eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`,
Severity: "medium",
},
{
Name: "generic-password",
Description: "Generic password/secret field assignment",
Raw: `(?i)(password|passwd|pwd|secret)['"]?\s*[:=]\s*['"][A-Za-z0-9!@#$%^&*()_+\-=\[\]{}|;:,.<>?]{8,}`,
Severity: "medium",
},
{
Name: "npm-token",
Description: "npm access token",
Raw: `npm_[A-Za-z0-9]{36,}`,
Severity: "high",
},
{
Name: "pg-connection-string",
Description: "PostgreSQL connection string",
Raw: `postgres(ql)?://[A-Za-z0-9_]+:[^@\s]+@`,
Severity: "high",
},
{
Name: "redis-connection-string",
Description: "Redis connection string with password",
Raw: `redis://[^:@\s]+:[^@\s]+@`,
Severity: "high",
},
}
+118
View File
@@ -0,0 +1,118 @@
package scanning
import (
"regexp"
"testing"
)
func TestPatternsCompile(t *testing.T) {
for _, p := range Patterns {
_, err := regexp.Compile(p.Raw)
if err != nil {
t.Errorf("pattern %q failed to compile: %v", p.Name, err)
}
}
}
func TestPatternsHaveNames(t *testing.T) {
for _, p := range Patterns {
if p.Name == "" {
t.Error("pattern with empty name")
}
if p.Description == "" {
t.Errorf("pattern %q has empty description", p.Name)
}
if p.Severity != "high" && p.Severity != "medium" && p.Severity != "low" {
t.Errorf("pattern %q has invalid severity %q", p.Name, p.Severity)
}
}
}
func TestAWSAccessKey(t *testing.T) {
re := regexp.MustCompile(`AKIA[0-9A-Z]{16}`)
cases := []struct {
input string
match bool
}{
{"AKIAIOSFODNN7EXAMPLE", true},
{"AKIA1234567890123456", true},
{"not-a-key", false},
{"SKIA1234567890123456", false},
}
for _, tc := range cases {
got := re.MatchString(tc.input)
if got != tc.match {
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match)
}
}
}
func TestGitHubToken(t *testing.T) {
re := regexp.MustCompile(`gh[pousr]_[A-Za-z0-9_]{36,}`)
cases := []struct {
input string
match bool
}{
{"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"ghr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"not-a-token", false},
{"ghp_short", false},
}
for _, tc := range cases {
got := re.MatchString(tc.input)
if got != tc.match {
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match)
}
}
}
func TestPrivateKey(t *testing.T) {
re := regexp.MustCompile(`-----BEGIN\s+(RSA|EC|OPENSSH|DSA|PRIVATE)(\s+PRIVATE)?\s+KEY-----`)
cases := []struct {
input string
match bool
}{
{"-----BEGIN RSA PRIVATE KEY-----", true},
{"-----BEGIN EC PRIVATE KEY-----", true},
{"-----BEGIN OPENSSH PRIVATE KEY-----", true},
{"-----BEGIN DSA PRIVATE KEY-----", true},
{"-----BEGIN PRIVATE KEY-----", true},
{"-----BEGIN CERTIFICATE-----", false},
{"public key is here", false},
}
for _, tc := range cases {
got := re.MatchString(tc.input)
if got != tc.match {
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match)
}
}
}
func TestJWT(t *testing.T) {
re := regexp.MustCompile(`eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`)
cases := []struct {
input string
match bool
}{
{"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNnZctV9XjvP_oGZQZxGdAqVxQ", true},
{"not-a-jwt", false},
}
for _, tc := range cases {
got := re.MatchString(tc.input)
if got != tc.match {
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match)
}
}
}
func TestTruncate(t *testing.T) {
if truncate("hello", 10) != "hello" {
t.Error("should not truncate short strings")
}
if truncate("hello world this is long", 10) != "hello worl..." {
t.Errorf("got %q", truncate("hello world this is long", 10))
}
}
+189
View File
@@ -0,0 +1,189 @@
// Package signing provides ECDSA P-256 artifact signing and verification.
//
// Every artifact uploaded through the API is automatically signed by the
// server's signing key. The resulting Bundle is self-contained: it carries
// the payload JSON, the base64-encoded ASN.1 signature, and the signer's
// public key PEM, so any verifier can reconstruct the check without needing
// access to the server's private key.
package signing
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log"
"time"
)
// KeyStore holds the server signing key pair.
type KeyStore struct {
privateKey *ecdsa.PrivateKey
publicKeyPEM string
keyID string // 16-char hex fingerprint of the DER public key
}
// New creates a KeyStore from a PEM-encoded ECDSA private key.
func New(privateKeyPEM string) (*KeyStore, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, errors.New("signing: invalid PEM block")
}
key, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("signing: parse private key: %w", err)
}
return newFromKey(key)
}
// Generate creates a fresh ephemeral ECDSA P-256 key pair.
// Logs a warning — not suitable for production; use ARTIFACT_SIGNING_KEY env var.
func Generate() (*KeyStore, error) {
log.Println("signing: ARTIFACT_SIGNING_KEY not set — generating ephemeral key (signatures will not survive restart)")
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("signing: generate key: %w", err)
}
return newFromKey(key)
}
func newFromKey(key *ecdsa.PrivateKey) (*KeyStore, error) {
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return nil, fmt.Errorf("signing: marshal public key: %w", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
sum := sha256.Sum256(pubDER)
keyID := fmt.Sprintf("%x", sum[:8])
return &KeyStore{privateKey: key, publicKeyPEM: pubPEM, keyID: keyID}, nil
}
// PrivateKeyPEM serialises the private key so callers can persist it.
func (ks *KeyStore) PrivateKeyPEM() (string, error) {
der, err := x509.MarshalECPrivateKey(ks.privateKey)
if err != nil {
return "", err
}
return string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})), nil
}
// PublicKeyPEM returns the signer's PEM public key (embedded in every bundle).
func (ks *KeyStore) PublicKeyPEM() string { return ks.publicKeyPEM }
// KeyID returns the short fingerprint of the public key.
func (ks *KeyStore) KeyID() string { return ks.keyID }
// ─── Bundle types ─────────────────────────────────────────────────────────────
const bundleMediaType = "application/vnd.forgebucket.signature.bundle+json"
// Bundle is the self-contained signature artifact stored alongside each upload.
type Bundle struct {
MediaType string `json:"mediaType"`
Payload BundlePayload `json:"payload"`
Signature string `json:"signature"` // base64(ASN.1 DER ECDSA signature)
PublicKey string `json:"publicKey"` // PEM-encoded ECDSA public key
KeyID string `json:"keyId"`
}
// BundlePayload is the data that was signed (JSON-serialised before hashing).
type BundlePayload struct {
ArtifactID int64 `json:"artifactId"`
Name string `json:"name"`
Digest string `json:"digest"` // "sha256:<hex>"
SignedAt string `json:"signedAt"` // RFC 3339
}
// ─── Sign ─────────────────────────────────────────────────────────────────────
// Sign computes SHA-256(rawContent), builds a BundlePayload, signs
// SHA-256(JSON(payload)) with the private key, and returns the Bundle.
func (ks *KeyStore) Sign(artifactID int64, name string, rawContent []byte) (*Bundle, error) {
contentDigest := sha256.Sum256(rawContent)
payload := BundlePayload{
ArtifactID: artifactID,
Name: name,
Digest: fmt.Sprintf("sha256:%x", contentDigest),
SignedAt: time.Now().UTC().Format(time.RFC3339),
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("signing: marshal payload: %w", err)
}
payloadHash := sha256.Sum256(payloadJSON)
sigDER, err := ecdsa.SignASN1(rand.Reader, ks.privateKey, payloadHash[:])
if err != nil {
return nil, fmt.Errorf("signing: ecdsa sign: %w", err)
}
return &Bundle{
MediaType: bundleMediaType,
Payload: payload,
Signature: base64.StdEncoding.EncodeToString(sigDER),
PublicKey: ks.publicKeyPEM,
KeyID: ks.keyID,
}, nil
}
// ─── Verify ───────────────────────────────────────────────────────────────────
// VerifyResult is returned by both verification functions.
type VerifyResult struct {
Valid bool `json:"valid"`
Digest string `json:"digest"`
SignedAt string `json:"signedAt"`
KeyID string `json:"keyId"`
KeyMatches bool `json:"keyMatchesServer"` // true if bundle public key == server public key
}
// Verify parses bundleJSON, verifies the embedded signature against the
// embedded public key, and returns a VerifyResult.
// The caller should also check KeyMatches to confirm it was signed by this server.
func (ks *KeyStore) Verify(bundleJSON []byte) (*VerifyResult, error) {
var b Bundle
if err := json.Unmarshal(bundleJSON, &b); err != nil {
return nil, fmt.Errorf("signing: parse bundle: %w", err)
}
block, _ := pem.Decode([]byte(b.PublicKey))
if block == nil {
return nil, errors.New("signing: invalid public key PEM in bundle")
}
pubInterface, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("signing: parse public key: %w", err)
}
pub, ok := pubInterface.(*ecdsa.PublicKey)
if !ok {
return nil, errors.New("signing: public key is not ECDSA")
}
payloadJSON, err := json.Marshal(b.Payload)
if err != nil {
return nil, fmt.Errorf("signing: marshal payload: %w", err)
}
payloadHash := sha256.Sum256(payloadJSON)
sigDER, err := base64.StdEncoding.DecodeString(b.Signature)
if err != nil {
return nil, fmt.Errorf("signing: decode signature base64: %w", err)
}
valid := ecdsa.VerifyASN1(pub, payloadHash[:], sigDER)
return &VerifyResult{
Valid: valid,
Digest: b.Payload.Digest,
SignedAt: b.Payload.SignedAt,
KeyID: b.KeyID,
KeyMatches: b.PublicKey == ks.publicKeyPEM,
}, nil
}
+153
View File
@@ -0,0 +1,153 @@
package signing_test
import (
"encoding/json"
"testing"
"github.com/forgeo/forgebucket/internal/domain/signing"
)
func TestGenerateAndSign(t *testing.T) {
ks, err := signing.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if ks.KeyID() == "" {
t.Fatal("expected non-empty key ID")
}
if ks.PublicKeyPEM() == "" {
t.Fatal("expected non-empty public key PEM")
}
}
func TestSignAndVerify(t *testing.T) {
ks, err := signing.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
content := []byte("hello, forgebucket artifact")
bundle, err := ks.Sign(42, "binary.tar.gz", content)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if bundle.MediaType != "application/vnd.forgebucket.signature.bundle+json" {
t.Errorf("unexpected media type: %s", bundle.MediaType)
}
if bundle.Payload.ArtifactID != 42 {
t.Errorf("artifact ID mismatch: got %d", bundle.Payload.ArtifactID)
}
if bundle.Payload.Name != "binary.tar.gz" {
t.Errorf("name mismatch: got %s", bundle.Payload.Name)
}
if bundle.Payload.Digest == "" {
t.Error("expected non-empty digest")
}
if bundle.Signature == "" {
t.Error("expected non-empty signature")
}
bundleJSON, err := json.Marshal(bundle)
if err != nil {
t.Fatalf("marshal bundle: %v", err)
}
result, err := ks.Verify(bundleJSON)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !result.Valid {
t.Error("expected valid=true")
}
if !result.KeyMatches {
t.Error("expected keyMatchesServer=true")
}
if result.Digest != bundle.Payload.Digest {
t.Errorf("digest mismatch: %s vs %s", result.Digest, bundle.Payload.Digest)
}
}
func TestVerifyTamperedSignature(t *testing.T) {
ks, _ := signing.Generate()
content := []byte("artifact content")
bundle, err := ks.Sign(1, "file.bin", content)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Tamper: valid base64 but not a valid ECDSA signature over this payload.
// "Z2FyYmFnZQ==" decodes to "garbage" which is not a valid DER ECDSA sig.
bundle.Signature = "Z2FyYmFnZQ=="
bundleJSON, _ := json.Marshal(bundle)
result, err := ks.Verify(bundleJSON)
if err != nil {
t.Fatalf("Verify should not error on invalid sig: %v", err)
}
if result.Valid {
t.Error("expected valid=false for tampered signature")
}
}
func TestVerifyWrongKey(t *testing.T) {
ks1, _ := signing.Generate()
ks2, _ := signing.Generate()
content := []byte("artifact")
bundle, err := ks1.Sign(10, "tool", content)
if err != nil {
t.Fatalf("Sign: %v", err)
}
bundleJSON, _ := json.Marshal(bundle)
// Verify with ks2 — key won't match.
result, err := ks2.Verify(bundleJSON)
if err != nil {
t.Fatalf("Verify: %v", err)
}
// Cryptographic signature is still valid (uses embedded pub key), but key doesn't match server.
if !result.Valid {
t.Error("signature itself should still be cryptographically valid")
}
if result.KeyMatches {
t.Error("expected keyMatchesServer=false when signed by a different key")
}
}
func TestNewFromPEM(t *testing.T) {
ks1, err := signing.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
pemStr, err := ks1.PrivateKeyPEM()
if err != nil {
t.Fatalf("PrivateKeyPEM: %v", err)
}
ks2, err := signing.New(pemStr)
if err != nil {
t.Fatalf("New from PEM: %v", err)
}
if ks1.KeyID() != ks2.KeyID() {
t.Errorf("key IDs differ: %s vs %s", ks1.KeyID(), ks2.KeyID())
}
// Sign with ks1, verify with ks2 (same underlying key).
bundle, _ := ks1.Sign(5, "bin", []byte("data"))
bundleJSON, _ := json.Marshal(bundle)
result, err := ks2.Verify(bundleJSON)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !result.Valid {
t.Error("expected valid=true")
}
if !result.KeyMatches {
t.Error("expected keyMatchesServer=true for same key material")
}
}
+140
View File
@@ -0,0 +1,140 @@
package vulnscan
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const defaultOSVAPI = "https://api.osv.dev/v1"
// Client queries the OSV (Open Source Vulnerabilities) API.
// https://osv.dev/docs/
type Client struct {
baseURL string
http *http.Client
}
// NewClient creates a client that queries the public OSV API.
func NewClient() *Client {
return &Client{
baseURL: defaultOSVAPI,
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// QueryRequest is sent to POST /v1/query.
type QueryRequest struct {
Package PackageID `json:"package"`
Version string `json:"version"`
}
// PackageID identifies a package in a specific ecosystem.
type PackageID struct {
PURL string `json:"purl,omitempty"`
Name string `json:"name,omitempty"`
Ecosystem string `json:"ecosystem,omitempty"`
}
// QueryResponse is the response from POST /v1/query.
type QueryResponse struct {
Vulns []OSVVuln `json:"vulns"`
}
// OSVVuln is a vulnerability returned by the OSV API.
type OSVVuln struct {
ID string `json:"id"`
Summary string `json:"summary"`
Details string `json:"details"`
Aliases []string `json:"aliases"`
Fixed string `json:"fixed,omitempty"`
Severity []Severity `json:"severity,omitempty"`
Affected []Affected `json:"affected,omitempty"`
Published string `json:"published,omitempty"`
Modified string `json:"modified,omitempty"`
}
// Severity holds a CVSS score from the OSV response.
type Severity struct {
Type string `json:"type"`
Score string `json:"score"`
}
// Affected describes a package version range.
type Affected struct {
Package PackageID `json:"package"`
Ranges []AffectedRange `json:"ranges"`
Versions []string `json:"versions"`
}
type AffectedRange struct {
Type string `json:"type"`
Events []RangeEvent `json:"events"`
}
type RangeEvent struct {
Introduced string `json:"introduced"`
Fixed string `json:"fixed"`
Limit string `json:"limit"`
}
// QueryByPURL queries OSV for vulnerabilities affecting a given PURL + version.
func (c *Client) QueryByPURL(purl, version string) ([]OSVVuln, error) {
body := QueryRequest{
Package: PackageID{PURL: purl},
Version: version,
}
return c.doQuery(body)
}
// QueryByEcosystem queries OSV for vulnerabilities affecting a package in a
// specific ecosystem (e.g. "npm", "Go", "PyPI", "cargo", "Maven", "RubyGems").
func (c *Client) QueryByEcosystem(ecosystem, name, version string) ([]OSVVuln, error) {
body := QueryRequest{
Package: PackageID{
Name: name,
Ecosystem: ecosystem,
},
Version: version,
}
return c.doQuery(body)
}
func (c *Client) doQuery(body interface{}) ([]OSVVuln, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("vulnscan: marshal body: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/query", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("vulnscan: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("vulnscan: query: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("vulnscan: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("vulnscan: OSV returned %d: %s", resp.StatusCode, string(respBody))
}
var qr QueryResponse
if err := json.Unmarshal(respBody, &qr); err != nil {
return nil, fmt.Errorf("vulnscan: parse response: %w", err)
}
return qr.Vulns, nil
}
+89
View File
@@ -0,0 +1,89 @@
package vulnscan
import (
"encoding/json"
"testing"
)
func TestParseCVSS(t *testing.T) {
v := OSVVuln{
ID: "CVE-2024-0001",
Severity: []Severity{
{Type: "CVSS_V3", Score: "7.5"},
},
}
score := parseCVSS(v)
if score != 7.5 {
t.Errorf("expected 7.5, got %f", score)
}
}
func TestParseCVSS_NoScore(t *testing.T) {
v := OSVVuln{
ID: "GHSA-xxxx",
}
score := parseCVSS(v)
if score != 0 {
t.Errorf("expected 0 for no severity, got %f", score)
}
}
func TestExtractFixedVersion(t *testing.T) {
v := OSVVuln{
Affected: []Affected{
{
Ranges: []AffectedRange{
{
Events: []RangeEvent{
{Introduced: "0"},
{Fixed: "1.2.3"},
},
},
},
},
},
}
fixed := extractFixedVersion(v)
if fixed != "1.2.3" {
t.Errorf("expected 1.2.3, got %s", fixed)
}
}
func TestExtractFixedVersion_None(t *testing.T) {
v := OSVVuln{}
fixed := extractFixedVersion(v)
if fixed != "" {
t.Errorf("expected empty, got %s", fixed)
}
}
func TestTruncateStr(t *testing.T) {
if truncateStr("short", 10) != "short" {
t.Error("should not truncate short strings")
}
if truncateStr("this is a long string", 10) != "this is a ..." {
t.Errorf("got %q", truncateStr("this is a long string", 10))
}
}
func TestNewClient(t *testing.T) {
c := NewClient()
if c.baseURL != defaultOSVAPI {
t.Errorf("baseURL = %s, want %s", c.baseURL, defaultOSVAPI)
}
}
func TestQueryRequest_Marshal(t *testing.T) {
body := QueryRequest{
Package: PackageID{PURL: "pkg:golang/github.com/foo/bar@v1.0.0"},
Version: "v1.0.0",
}
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Ensure it produces valid JSON.
if len(data) == 0 {
t.Error("empty JSON")
}
}
+180
View File
@@ -0,0 +1,180 @@
package vulnscan
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// Scanner watches for SBOM generation events and queries OSV for vulns.
type Scanner struct {
db *xorm.Engine
bus events.EventBus
client *Client
}
func NewScanner(db *xorm.Engine, bus events.EventBus) *Scanner {
return &Scanner{
db: db,
bus: bus,
client: NewClient(),
}
}
// Start subscribes to SBOM-related events and scans for vulnerabilities.
func (s *Scanner) Start(ctx context.Context) {
// Listen for SBOM Report created events (sync trigger).
// In practice this is called on-demand via the API, so Start is minimal.
<-ctx.Done()
}
// ScanByPURL queries OSV for a single package and stores findings.
func (s *Scanner) ScanByPURL(repoID int64, purl, version string) ([]models.VulnerabilityFinding, error) {
vulns, err := s.client.QueryByPURL(purl, version)
if err != nil {
return nil, err
}
return s.persistFindings(repoID, purl, version, vulns), nil
}
// ScanSBOM reads the latest SBOM report for a repo, queries OSV for every
// component, and stores the findings. Returns the new findings.
func (s *Scanner) ScanSBOM(repoID int64) ([]models.VulnerabilityFinding, error) {
var report models.SBOMReport
found, err := s.db.Where("repo_id = ?", repoID).
OrderBy("generated_at DESC").Get(&report)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("no SBOM found for repo %d", repoID)
}
var doc struct {
Components []struct {
Name string `json:"name"`
Version string `json:"version"`
PURL string `json:"purl"`
} `json:"components"`
}
if err := json.Unmarshal([]byte(report.BOMDocument), &doc); err != nil {
return nil, fmt.Errorf("parse SBOM: %w", err)
}
var allFindings []models.VulnerabilityFinding
for _, comp := range doc.Components {
if comp.PURL == "" || comp.Version == "" {
continue
}
vulns, err := s.client.QueryByPURL(comp.PURL, comp.Version)
if err != nil {
log.Printf("vulnscan: query %s@%s: %v", comp.PURL, comp.Version, err)
continue
}
findings := s.persistFindings(repoID, comp.PURL, comp.Version, vulns)
allFindings = append(allFindings, findings...)
}
return allFindings, nil
}
// ListFindings returns unfixed vulnerability findings for a repo.
func (s *Scanner) ListFindings(repoID int64) ([]models.VulnerabilityFinding, error) {
var findings []models.VulnerabilityFinding
if err := s.db.Where("repo_id = ? AND dismissed = ?", repoID, false).
OrderBy("cvss_score DESC, detected_at DESC").Find(&findings); err != nil {
return nil, err
}
if findings == nil {
findings = []models.VulnerabilityFinding{}
}
return findings, nil
}
// DismissFindings acknowledges a vulnerability finding.
func (s *Scanner) DismissFindings(findingID int64, dismissedBy string) error {
now := time.Now().UTC()
affected, err := s.db.ID(findingID).Cols("dismissed", "dismissed_by", "dismissed_at").
Update(&models.VulnerabilityFinding{
Dismissed: true,
DismissedBy: dismissedBy,
DismissedAt: &now,
})
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("finding %d not found", findingID)
}
return nil
}
func (s *Scanner) persistFindings(repoID int64, purl, version string, vulns []OSVVuln) []models.VulnerabilityFinding {
var findings []models.VulnerabilityFinding
for _, v := range vulns {
// Check for duplicate before inserting.
existing := &models.VulnerabilityFinding{}
if has, _ := s.db.Where("vuln_id = ? AND purl = ? AND repo_id = ?", v.ID, purl, repoID).Get(existing); has {
continue
}
cvssScore := parseCVSS(v)
finding := &models.VulnerabilityFinding{
RepoID: repoID,
VulnID: v.ID,
PURL: purl,
Version: version,
Summary: truncateStr(v.Summary, 300),
Details: v.Details,
CVSSScore: cvssScore,
FixedVersion: extractFixedVersion(v),
DetectedAt: time.Now().UTC(),
}
if _, err := s.db.Insert(finding); err != nil {
log.Printf("vulnscan: insert finding %s for %s: %v", v.ID, purl, err)
continue
}
findings = append(findings, *finding)
}
return findings
}
// parseCVSS extracts the CVSS score from OSV severity info.
func parseCVSS(v OSVVuln) float64 {
for _, sev := range v.Severity {
if sev.Type == "CVSS_V3" || sev.Type == "CVSS_V2" {
var score float64
fmt.Sscanf(sev.Score, "%f", &score)
return score
}
}
return 0
}
// extractFixedVersion tries to extract the fixed version from affected ranges.
func extractFixedVersion(v OSVVuln) string {
for _, a := range v.Affected {
for _, r := range a.Ranges {
for _, e := range r.Events {
if e.Fixed != "" {
return e.Fixed
}
}
}
}
return ""
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+4
View File
@@ -16,6 +16,7 @@ import (
type EventBus interface {
Publish(subject string, payload any) error
Subscribe(subject string, handler func(subject string, data []byte)) (func(), error)
Healthy() bool
Close()
}
@@ -63,6 +64,8 @@ func (b *NATSBus) Subscribe(subject string, handler func(subject string, data []
return func() { sub.Unsubscribe() }, nil //nolint:errcheck
}
func (b *NATSBus) Healthy() bool { return b.nc.IsConnected() }
func (b *NATSBus) Close() {
if err := b.nc.Drain(); err != nil {
log.Printf("nats: drain: %v", err)
@@ -75,6 +78,7 @@ type NoOpBus struct{}
func (NoOpBus) Publish(_ string, _ any) error { return nil }
func (NoOpBus) Subscribe(_ string, _ func(string, []byte)) (func(), error) { return func() {}, nil }
func (NoOpBus) Healthy() bool { return true }
func (NoOpBus) Close() {}
// New returns a NATSBus if url is non-empty, otherwise a NoOpBus.
+23
View File
@@ -79,6 +79,29 @@ type LogChunkEvent struct {
Content string `json:"content"`
}
// DeploymentEvent is published on deployment lifecycle transitions.
// It matches the payload shape used by EnvironmentHandler.publishDeployEvent.
type DeploymentEvent struct {
DeploymentID int64 `json:"deploymentId"`
EnvID int64 `json:"envId"`
EnvName string `json:"envName"`
RepoID int64 `json:"repoId"`
SHA string `json:"sha"`
Ref string `json:"ref"`
Status string `json:"status"`
TriggeredBy string `json:"triggeredBy"`
}
// DriftEvent is published when an environment's actual state diverges from desired.
type DriftEvent struct {
EnvID int64 `json:"envId"`
EnvName string `json:"envName"`
RepoID int64 `json:"repoId"`
DesiredSHA string `json:"desiredSha"`
ActualSHA string `json:"actualSha"`
At time.Time `json:"at"`
}
// WSEnvelope wraps any event for delivery over the WebSocket connection.
type WSEnvelope struct {
Subject string `json:"subject"`
+22
View File
@@ -2,6 +2,28 @@ package models
import "time"
// FederationActivity stores all inbound and outbound ActivityPub activities.
type FederationActivity struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
ActorAPID string `xorm:"'actor_ap_id' notnull index varchar(500)" json:"actorApId"`
Type string `xorm:"'type' notnull varchar(50)" json:"type"`
ObjectJSON string `xorm:"'object_json' text" json:"objectJson"`
Direction string `xorm:"'direction' notnull varchar(10)" json:"direction"` // inbound|outbound
RemoteActor string `xorm:"'remote_actor' varchar(500)" json:"remoteActor"`
Published time.Time `xorm:"'published' index" json:"published"`
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
}
// RemoteActor caches public actor documents fetched from remote instances.
type RemoteActor struct {
ID int64 `xorm:"'id' pk autoincr"`
APID string `xorm:"'ap_id' notnull unique varchar(500)"`
InboxURL string `xorm:"'inbox_url' varchar(500)"`
PublicKey string `xorm:"'public_key' text"`
FetchedAt time.Time `xorm:"'fetched_at'"`
CreatedAt time.Time `xorm:"'created_at' created"`
}
type FederationActor struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
UserID int64 `xorm:"'user_id' notnull unique index" json:"userId"`
+32
View File
@@ -0,0 +1,32 @@
package models
import "time"
// GitOpsConfig links an Environment to a branch that serves as its desired state.
// When the HEAD SHA of Branch diverges from ActualSHA, the environment is "drifted".
type GitOpsConfig struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
EnvID int64 `xorm:"'env_id' unique notnull index" json:"envId"` // one config per env
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
Branch string `xorm:"'branch' varchar(255) notnull" json:"branch"` // source-of-truth branch
AutoSync bool `xorm:"'auto_sync' default false" json:"autoSync"` // create deployment on drift
SyncInterval int `xorm:"'sync_interval' default 0" json:"syncInterval"` // seconds; 0 = push-only
SyncStatus string `xorm:"'sync_status' varchar(20) default 'unknown'" json:"syncStatus"` // unknown/synced/drifted/syncing
DesiredSHA string `xorm:"'desired_sha' varchar(40)" json:"desiredSha"` // last known branch HEAD
ActualSHA string `xorm:"'actual_sha' varchar(40)" json:"actualSha"` // SHA of last successful deploy
LastCheckedAt *time.Time `xorm:"'last_checked_at'" json:"lastCheckedAt"`
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
}
// GitOpsDriftEvent is an append-only record of each drift detection and its resolution.
type GitOpsDriftEvent struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
EnvID int64 `xorm:"'env_id' notnull index" json:"envId"`
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
DesiredSHA string `xorm:"'desired_sha' varchar(40)" json:"desiredSha"` // SHA that should be deployed
ActualSHA string `xorm:"'actual_sha' varchar(40)" json:"actualSha"` // SHA actually deployed (empty = never)
SyncStatus string `xorm:"'sync_status' varchar(20)" json:"syncStatus"` // drifted/synced/acknowledged
DetectedAt time.Time `xorm:"'detected_at' notnull index" json:"detectedAt"`
ResolvedAt *time.Time `xorm:"'resolved_at'" json:"resolvedAt"`
}
+25 -1
View File
@@ -46,5 +46,29 @@ func Run(engine *xorm.Engine) error {
if err := Run011(engine); err != nil {
return err
}
return Run012(engine)
if err := Run012(engine); err != nil {
return err
}
if err := Run013(engine); err != nil {
return err
}
if err := Run014(engine); err != nil {
return err
}
if err := Run015(engine); err != nil {
return err
}
if err := Run016(engine); err != nil {
return err
}
if err := Run017(engine); err != nil {
return err
}
if err := Run018(engine); err != nil {
return err
}
if err := Run019(engine); err != nil {
return err
}
return Run020(engine)
}
+13
View File
@@ -0,0 +1,13 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run013(engine *xorm.Engine) error {
return engine.Sync2(
&models.GitOpsConfig{},
&models.GitOpsDriftEvent{},
)
}
@@ -0,0 +1,13 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run014(engine *xorm.Engine) error {
return engine.Sync2(
&models.FederationActivity{},
&models.RemoteActor{},
)
}
+10
View File
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run015(engine *xorm.Engine) error {
return engine.Sync2(&models.ArtifactSignature{})
}
+10
View File
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run016(engine *xorm.Engine) error {
return engine.Sync2(&models.SBOMReport{})
}
+16
View File
@@ -0,0 +1,16 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run017(engine *xorm.Engine) error {
return engine.Sync2(
&models.OCIRepository{},
&models.OCIManifest{},
&models.OCITag{},
&models.OCIBlob{},
&models.OCIUpload{},
)
}
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run018(engine *xorm.Engine) error {
return engine.Sync2(&models.SecretLeak{})
}
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run019(engine *xorm.Engine) error {
return engine.Sync2(&models.VulnerabilityFinding{})
}
@@ -0,0 +1,13 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run020(engine *xorm.Engine) error {
if err := engine.Sync2(&models.Repository{}); err != nil {
return err
}
return engine.Sync2(&models.PullRequest{})
}
+53
View File
@@ -0,0 +1,53 @@
package models
import "time"
// OCIRepository represents a named image repository within the registry.
// Name mirrors the OCI distribution spec "name" component, e.g. "alice/myapp".
type OCIRepository struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"` // FK to Repository (git repo that owns this image)
Name string `xorm:"'name' varchar(255) unique" json:"name"` // e.g. "alice/myapp"
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
}
// OCIManifest stores a pushed image manifest (OCI or Docker schema2).
// The full manifest JSON is stored in Content so it can be streamed without
// going to disk. Manifests are small (typically <4 KB).
type OCIManifest struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
OCIRepoID int64 `xorm:"'oci_repo_id' notnull index" json:"ociRepoId"`
Digest string `xorm:"'digest' varchar(80) notnull" json:"digest"` // "sha256:<hex>"
MediaType string `xorm:"'media_type' varchar(150)" json:"mediaType"`
Size int64 `xorm:"'size'" json:"size"`
Content string `xorm:"'content' text" json:"-"` // raw JSON
PushedAt time.Time `xorm:"'pushed_at' created" json:"pushedAt"`
}
// OCITag maps a mutable tag (e.g. "latest", "v1.2.3") to a manifest digest.
type OCITag struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
OCIRepoID int64 `xorm:"'oci_repo_id' notnull index" json:"ociRepoId"`
Name string `xorm:"'name' varchar(128)" json:"name"`
Digest string `xorm:"'digest' varchar(80)" json:"digest"`
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
}
// OCIBlob tracks a content-addressable blob. The actual content lives at
// {oci_root}/blobs/sha256/<hex> on the filesystem.
type OCIBlob struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
Digest string `xorm:"'digest' varchar(80) unique" json:"digest"`
Size int64 `xorm:"'size'" json:"size"`
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
}
// OCIUpload tracks an in-progress blob upload session.
type OCIUpload struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
UploadID string `xorm:"'upload_id' varchar(64) unique" json:"uploadId"` // UUID used in URL
Name string `xorm:"'name' varchar(255)" json:"name"` // image name
Offset int64 `xorm:"'offset'" json:"offset"`
ExpiresAt time.Time `xorm:"'expires_at'" json:"expiresAt"`
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
}
+1
View File
@@ -19,6 +19,7 @@ type PullRequest struct {
SourceBranch string `xorm:"'source_branch' notnull varchar(255)" json:"sourceBranch"`
TargetBranch string `xorm:"'target_branch' default 'main' varchar(255)" json:"targetBranch"`
Status PRStatus `xorm:"'status' default 'open' varchar(16)" json:"status"`
RemoteSource string `xorm:"'remote_source' varchar(500)" json:"remoteSource,omitempty"` // APID of remote fork repo (cross-instance)
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
}
+1
View File
@@ -11,6 +11,7 @@ type Repository struct {
IsPrivate bool `xorm:"'is_private' default false" json:"isPrivate"`
DefaultBranch string `xorm:"'default_branch' default 'main' varchar(255)" json:"defaultBranch"`
DiskPath string `xorm:"'disk_path' notnull" json:"-"`
ForkedFrom string `xorm:"'forked_from' varchar(500)" json:"forkedFrom,omitempty"` // APID of the upstream repo
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
}
+17
View File
@@ -0,0 +1,17 @@
package models
import "time"
// SBOMReport stores the generated CycloneDX BOM for a repo at a specific SHA.
// BOMDocument holds the full JSON but is not returned by list endpoints —
// use the dedicated document endpoint to stream it.
type SBOMReport struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
RunID int64 `xorm:"'run_id' index" json:"runId"` // 0 = on-demand
SHA string `xorm:"'sha' varchar(40)" json:"sha"`
Format string `xorm:"'format' varchar(30)" json:"format"` // "cyclonedx-json-1.4"
ComponentCount int `xorm:"'component_count'" json:"componentCount"`
BOMDocument string `xorm:"'bom_document' text" json:"-"` // full JSON, not returned in lists
GeneratedAt time.Time `xorm:"'generated_at'" json:"generatedAt"`
}
+20
View File
@@ -0,0 +1,20 @@
package models
import "time"
// SecretLeak records a detected secret pattern in a pushed commit.
// When a match is confirmed not to be a real secret, set Dismissed=true.
type SecretLeak struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
CommitSHA string `xorm:"'commit_sha' varchar(12)" json:"commitSha"`
Ref string `xorm:"'ref' varchar(255)" json:"ref"`
PatternName string `xorm:"'pattern_name' varchar(50)" json:"patternName"`
Description string `xorm:"'description' varchar(200)" json:"description"`
Severity string `xorm:"'severity' varchar(10)" json:"severity"`
MatchSample string `xorm:"'match_sample' varchar(60)" json:"matchSample"`
Dismissed bool `xorm:"'dismissed'" json:"dismissed"`
DismissedBy string `xorm:"'dismissed_by' varchar(100)" json:"dismissedBy,omitempty"`
DismissedAt *time.Time `xorm:"'dismissed_at'" json:"dismissedAt,omitempty"`
DetectedAt time.Time `xorm:"'detected_at'" json:"detectedAt"`
}
+16
View File
@@ -0,0 +1,16 @@
package models
import "time"
// ArtifactSignature stores the Cosign-compatible signature bundle produced
// when an artifact is uploaded. The BundleJSON field is the full self-contained
// bundle so consumers can verify without hitting the API again.
type ArtifactSignature struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
ArtifactID int64 `xorm:"'artifact_id' notnull unique" json:"artifactId"`
KeyID string `xorm:"'key_id' varchar(32)" json:"keyId"`
Algorithm string `xorm:"'algorithm' varchar(50)" json:"algorithm"` // "ecdsa-p256-sha256"
Digest string `xorm:"'digest' varchar(80)" json:"digest"` // "sha256:<hex>"
BundleJSON string `xorm:"'bundle_json' text" json:"-"` // full bundle, not surfaced directly
SignedAt time.Time `xorm:"'signed_at'" json:"signedAt"`
}
+20
View File
@@ -0,0 +1,20 @@
package models
import "time"
// VulnerabilityFinding records a known vulnerability found in a dependency.
type VulnerabilityFinding struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
VulnID string `xorm:"'vuln_id' varchar(50)" json:"vulnId"` // e.g. "GHSA-xxxx" or "CVE-2024-..."
PURL string `xorm:"'purl' varchar(255)" json:"purl"` // package URL
Version string `xorm:"'version' varchar(100)" json:"version"` // affected version
Summary string `xorm:"'summary' varchar(500)" json:"summary"`
Details string `xorm:"'details' text" json:"details,omitempty"`
CVSSScore float64 `xorm:"'cvss_score'" json:"cvssScore"`
FixedVersion string `xorm:"'fixed_version' varchar(100)" json:"fixedVersion"`
Dismissed bool `xorm:"'dismissed'" json:"dismissed"`
DismissedBy string `xorm:"'dismissed_by' varchar(100)" json:"dismissedBy,omitempty"`
DismissedAt *time.Time `xorm:"'dismissed_at'" json:"dismissedAt,omitempty"`
DetectedAt time.Time `xorm:"'detected_at'" json:"detectedAt"`
}
+52
View File
@@ -0,0 +1,52 @@
package observability
import (
"fmt"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
)
const Version = "0.8.0"
// HealthStatus is the response shape for GET /health.
type HealthStatus struct {
Status string `json:"status"` // "healthy" | "degraded"
Checks map[string]string `json:"checks"` // dependency name → "ok" | error message
Version string `json:"version"`
}
// Check pings each critical dependency and returns a HealthStatus.
// HTTP status should be 200 when Status=="healthy", 503 when "degraded".
func Check(db *xorm.Engine, bus events.EventBus) HealthStatus {
checks := make(map[string]string, 2)
// Database — attempt a lightweight ping.
if err := db.Ping(); err != nil {
checks["database"] = fmt.Sprintf("error: %v", err)
} else {
checks["database"] = "ok"
}
// NATS — use the Healthy() method added in Phase 3E.
if bus.Healthy() {
checks["nats"] = "ok"
} else {
checks["nats"] = "disconnected"
}
overall := "healthy"
for _, v := range checks {
if v != "ok" {
overall = "degraded"
break
}
}
return HealthStatus{
Status: overall,
Checks: checks,
Version: Version,
}
}
+172
View File
@@ -0,0 +1,172 @@
package observability
import (
"context"
"encoding/json"
"log"
"net/http"
"regexp"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/forgeo/forgebucket/internal/events"
)
// ── Metric definitions ────────────────────────────────────────────────────────
var (
HttpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "forgebucket_http_requests_total",
Help: "Total HTTP requests by method, normalized path, and status code.",
}, []string{"method", "path", "status"})
HttpRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "forgebucket_http_request_duration_seconds",
Help: "HTTP request latency by method and normalized path.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "path"})
PipelineRunsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "forgebucket_pipeline_runs_total",
Help: "Pipeline runs by terminal status.",
}, []string{"status"})
DeploymentsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "forgebucket_deployments_total",
Help: "Deployments by terminal status.",
}, []string{"status"})
ActivePipelineRuns = promauto.NewGauge(prometheus.GaugeOpts{
Name: "forgebucket_active_pipeline_runs",
Help: "Pipeline runs currently in queued or running state.",
})
)
func init() {
// Pre-initialize all label combinations so the metrics are visible in
// /metrics immediately from startup (no gaps on first scrape).
for _, s := range []string{"succeeded", "failed", "cancelled"} {
PipelineRunsTotal.With(prometheus.Labels{"status": s})
}
for _, s := range []string{"pending", "success", "failure", "cancelled"} {
DeploymentsTotal.With(prometheus.Labels{"status": s})
}
}
// ── HTTP instrumentation middleware ──────────────────────────────────────────
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// Middleware records request count and latency for every HTTP request.
// Path labels are normalized to prevent high cardinality (numeric segments
// and positional path variables are replaced with placeholder tokens).
func Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
path := normalizePath(r.URL.Path)
status := strconv.Itoa(rec.status)
elapsed := time.Since(start).Seconds()
HttpRequestsTotal.WithLabelValues(r.Method, path, status).Inc()
HttpRequestDuration.WithLabelValues(r.Method, path).Observe(elapsed)
})
}
}
// normalizePath replaces volatile path segments with placeholders so that
// Prometheus label cardinality stays bounded.
//
// Examples:
//
// /api/v1/repos/alice/myrepo/runs/42/jobs/7/logs
// → /api/v1/repos/:owner/:repo/runs/:id/jobs/:id/logs
//
// /alice/myrepo.git/info/refs
// → /:owner/:repo.git/info/refs
var reNumeric = regexp.MustCompile(`/\d+`)
func normalizePath(path string) string {
// Replace all-numeric segments first.
path = reNumeric.ReplaceAllString(path, "/:id")
// Normalize repo smart-HTTP paths: /{owner}/{repo}.git/...
path = reGitPath.ReplaceAllString(path, "/:owner/:repo.git$1")
// Normalize /api/v1/repos/{owner}/{repo}/...
path = reRepoPath.ReplaceAllString(path, "/api/v1/repos/:owner/:repo$1")
// Normalize /api/v1/workspaces/{handle}/...
path = reWorkspacePath.ReplaceAllString(path, "/api/v1/workspaces/:handle$1")
return path
}
var (
reGitPath = regexp.MustCompile(`^/[^/]+/[^/]+\.git(/.*)$`)
reRepoPath = regexp.MustCompile(`^/api/v1/repos/[^/]+/[^/]+(/.*)$`)
reWorkspacePath = regexp.MustCompile(`^/api/v1/workspaces/[^/]+(/.*)$`)
)
// ── NATS event watcher ────────────────────────────────────────────────────────
// StartNATSWatcher subscribes to pipeline and deployment NATS events and
// increments the corresponding Prometheus counters. Runs until ctx is cancelled.
func StartNATSWatcher(ctx context.Context, bus events.EventBus) {
type statusPayload struct {
Status string `json:"status"`
}
unsub1, err := bus.Subscribe("pipeline.>", func(subject string, data []byte) {
switch subject {
case events.SubjectPipelineTriggered:
ActivePipelineRuns.Inc()
case events.SubjectPipelineCompleted:
ActivePipelineRuns.Dec()
PipelineRunsTotal.WithLabelValues("succeeded").Inc()
case events.SubjectPipelineFailed:
ActivePipelineRuns.Dec()
PipelineRunsTotal.WithLabelValues("failed").Inc()
}
})
if err != nil {
log.Printf("observability: subscribe pipeline.*: %v", err)
} else {
defer unsub1()
}
unsub2, err := bus.Subscribe("deployment.>", func(subject string, data []byte) {
var p statusPayload
json.Unmarshal(data, &p) //nolint:errcheck
switch subject {
case events.SubjectDeploymentSucceeded:
DeploymentsTotal.WithLabelValues("success").Inc()
case events.SubjectDeploymentFailed:
DeploymentsTotal.WithLabelValues("failure").Inc()
case events.SubjectDeploymentStarted:
DeploymentsTotal.WithLabelValues("pending").Inc()
}
})
if err != nil {
log.Printf("observability: subscribe deployment.*: %v", err)
} else {
defer unsub2()
}
log.Printf("observability: NATS metric watcher started")
<-ctx.Done()
}
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIKGMjCu0NdczHQ7BRDeo0hTOLauF9vOenWl0HlyN4bzToAoGCCqGSM49
AwEHoUQDQgAE+VL1HhQ1us0QfNH+5Var8lo5Oww83B+QDQ2obzHL4JZl0UM3kVAB
SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
-----END EC PRIVATE KEY-----