Compare commits
23 Commits
edf3c9824e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e7c64e583b | |||
| f658d754a8 | |||
| a7b1fd2ae3 | |||
| ee1b56e833 | |||
| 2d6aabab9f | |||
| 54d6e6be36 | |||
| 7196b9f264 | |||
| f675032786 | |||
| cff6701864 | |||
| 469d900ac8 | |||
| 366941feb1 | |||
| df6d53c12c | |||
| d384af0d9c | |||
| dea58b85b8 | |||
| 994570ca74 | |||
| 77268e2302 | |||
| f99f0e0fc5 | |||
| 91462500f0 | |||
| 822f723ff1 | |||
| ab94775162 | |||
| e360f3697e | |||
| c7df53708c | |||
| 35afa8d8f1 |
@@ -21,7 +21,7 @@ NATS_URL=nats://localhost:4222
|
|||||||
|
|
||||||
# ─── Federation (ActivityPub) ─────────────────────────────────────────────────
|
# ─── Federation (ActivityPub) ─────────────────────────────────────────────────
|
||||||
# Public URL of this instance (no trailing slash)
|
# 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
|
INSTANCE_NAME=ForgeBucket
|
||||||
|
|
||||||
# ─── OIDC / OAuth2 (optional) ────────────────────────────────────────────────
|
# ─── OIDC / OAuth2 (optional) ────────────────────────────────────────────────
|
||||||
@@ -31,4 +31,17 @@ INSTANCE_NAME=ForgeBucket
|
|||||||
|
|
||||||
# ─── Dev only ─────────────────────────────────────────────────────────────────
|
# ─── Dev only ─────────────────────────────────────────────────────────────────
|
||||||
# Set to true to disable Secure cookies and enable verbose logging
|
# 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
|
||||||
|
|||||||
@@ -26,11 +26,25 @@ INSTANCE_NAME=ForgeBucket
|
|||||||
# OIDC_CLIENT_ID=
|
# OIDC_CLIENT_ID=
|
||||||
# OIDC_CLIENT_SECRET=
|
# 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) ────────────────────────────────────────────────────────
|
# ─── Event Bus (NATS) ────────────────────────────────────────────────────────
|
||||||
# Leave empty to disable event publishing (no-op mode).
|
# Leave empty to disable event publishing (no-op mode).
|
||||||
# Start NATS with: make docker-up
|
# Start NATS with: make docker-up
|
||||||
NATS_URL=nats://localhost:4222
|
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 ─────────────────────────────────────────────────────────────────
|
# ─── Dev only ─────────────────────────────────────────────────────────────────
|
||||||
# Set to true to disable Secure cookies and enable verbose logging
|
# Set to true to disable Secure cookies and enable verbose logging
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
|||||||
+4
-1
@@ -12,4 +12,7 @@ cache
|
|||||||
uploads
|
uploads
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
ai_agent_master_prompt_for_building_modern_git_platform.md
|
||||||
|
html docs/
|
||||||
|
|||||||
@@ -19,73 +19,87 @@ The full product vision lives in [`ai_agent_master_prompt_for_building_modern_gi
|
|||||||
## Architecture Map
|
## Architecture Map
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/forgebucket/ — binary entry point (main.go)
|
cmd/forgebucket/ — binary entry point (main.go)
|
||||||
internal/
|
internal/
|
||||||
api/
|
api/
|
||||||
router.go — Chi router, all route definitions (~26 routes)
|
router.go — Chi router, all route definitions (60+ routes)
|
||||||
middleware/ — auth, CSRF, RBAC, logging
|
middleware/ — auth.go, csrf.go, rbac.go, audit.go
|
||||||
handlers/ — one file per domain (repo, pr, issue, auth, user, ssh...)
|
handlers/ — one file per domain area (see Key Files below)
|
||||||
domain/
|
domain/
|
||||||
git/ — sanitized git binary wrapper (exec.Command only, no shell)
|
git/ — sanitized git binary wrapper (exec.Command only, no shell)
|
||||||
federation/ — ActivityPub / ForgeFed (DATA LAYER ONLY — no handlers yet)
|
binary.go — Run, Log, Tree, Diff, BlobCat, RevParse, etc.
|
||||||
ci/ — CI orchestrator (EMPTY — Phase 2 stub)
|
agit.go — AGit ref parsing
|
||||||
models/ — XORM structs + 7 migration files
|
ci/ — CI/CD execution engine (fully built — Phase 2B)
|
||||||
config/ — ENV-driven config, fails fast on missing secrets
|
orchestrator.go — NATS-driven DAG orchestrator
|
||||||
web/ — //go:embed target for the built React SPA
|
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/
|
frontend/
|
||||||
src/
|
src/
|
||||||
pages/ — 20 route-level page components
|
pages/ — route-level page components
|
||||||
components/ — shared UI (AppShell, Sidebar, Header, DiffViewer, etc.)
|
components/ — shared UI (AppShell, Sidebar, Header, DiffViewer, etc.)
|
||||||
ui/
|
ui/
|
||||||
tokens.ts — SINGLE SOURCE OF TRUTH for all design tokens
|
tokens.ts — SINGLE SOURCE OF TRUTH for all design tokens
|
||||||
hooks/ — custom React hooks
|
hooks/ — custom React hooks
|
||||||
api/ — typed API client (fetch wrappers)
|
api/ — typed API client (fetch wrappers)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Middleware chain — this order is fixed, do not reorder:**
|
**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
|
## Current Phase Status
|
||||||
|
|
||||||
Understand the phases before adding code — don't build Phase 3 infrastructure when Phase 2 is incomplete.
|
|
||||||
|
|
||||||
| Phase | Scope | Status |
|
| Phase | Scope | Status |
|
||||||
|-------|-------|--------|
|
|-------|-------|--------|
|
||||||
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system, 20-page SPA | **Complete** |
|
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system | **Complete** |
|
||||||
| 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** |
|
| 2A | NATS event bus, WebSocket hub, audit log | **Complete** |
|
||||||
| 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **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** |
|
| 3A | Environment model + deployment tracking | **Complete** |
|
||||||
| 3B | Unified operational timeline | **Complete** |
|
| 3B | Unified operational timeline | **Complete** |
|
||||||
| 3C | Workspaces + secret management hierarchy | **Active** |
|
| 3C | Workspaces + secret management (Global → Workspace → Repo → Env) | **Complete** |
|
||||||
| 3D | GitOps controller + drift detection | Planned |
|
| 3D | GitOps controller + drift detection + auto-sync | **Complete** |
|
||||||
| 3E | Observability (Prometheus, health sparklines) | Planned |
|
| 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | **Complete** |
|
||||||
| 3F | Federation handlers (ActivityPub inbox/outbox) | Planned |
|
| 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures, Follow/Accept) | **Complete** |
|
||||||
| 4 | AI diagnostics, signed artifacts, OCI registry, secret/dep scanning | Planned |
|
| 4 | SBOM generation, secret scanning, vuln scanning, signed artifacts, OCI registry, security page | **Complete** |
|
||||||
|
| 5 | Deployment promotions, rollback visualization | 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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -107,13 +121,19 @@ This rule is non-negotiable. It prevents command injection.
|
|||||||
### Router / handlers
|
### Router / handlers
|
||||||
- Chi router. Route definitions in `internal/api/router.go`.
|
- Chi router. Route definitions in `internal/api/router.go`.
|
||||||
- One handler file per domain area. Keep handlers thin — business logic belongs in domain packages.
|
- 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
|
### Database
|
||||||
- XORM for all DB access. Structs in `internal/models/`.
|
- 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.
|
- 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
|
### Secrets and config
|
||||||
- All secrets come from environment variables via `internal/config/`.
|
- All secrets come from environment variables via `internal/config/`.
|
||||||
- Never hardcode secrets, tokens, or credentials anywhere.
|
- Never hardcode secrets, tokens, or credentials anywhere.
|
||||||
@@ -121,7 +141,7 @@ This rule is non-negotiable. It prevents command injection.
|
|||||||
|
|
||||||
### Error handling
|
### Error handling
|
||||||
- Return errors up the call stack. Don't swallow them silently.
|
- 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).
|
- Touch targets: 44px minimum height/width on all interactive elements (buttons, links, icon buttons).
|
||||||
|
|
||||||
### Dark mode
|
### 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.
|
- Colors must work in both light and dark modes. Test both.
|
||||||
|
|
||||||
### Component patterns
|
### Component patterns
|
||||||
@@ -155,20 +175,21 @@ All spacing, color, and sizing values must come from `frontend/src/ui/tokens.ts`
|
|||||||
|
|
||||||
### API calls
|
### API calls
|
||||||
- Use the typed API client in `frontend/src/api/` — don't write raw `fetch` calls in components.
|
- 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
|
## 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 hardcoded secrets** — everything via env
|
||||||
- **No skipping CSRF** — all mutating routes require it
|
- **No skipping CSRF** — all mutating routes require it
|
||||||
- **No arbitrary design values** — tokens.ts is the law
|
- **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 without discussion** — the existing palette covers all cases
|
||||||
- **No new color tokens** — if the design requires a new color, discuss it; don't invent one
|
- **No modal-heavy UX** — progressive disclosure; avoid deep modal chains
|
||||||
- **No modal-heavy UX** — this platform uses progressive disclosure; avoid deep modal chains
|
- **No YAML-centric UI** — pipeline and GitOps config should feel operational, not config-file editing
|
||||||
- **No YAML-centric UI** — pipeline and environment 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 |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `internal/api/router.go` | All route definitions — start here for backend |
|
| `internal/api/router.go` | All route definitions — start here for backend work |
|
||||||
| `internal/models/` | XORM models + migrations — all DB schemas |
|
| `internal/api/handlers/repo_lookup.go` | Shared `resolveRepoID` helper |
|
||||||
| `internal/config/config.go` | Env-driven config, required vars |
|
| `internal/models/` | All XORM models + 13 migration files |
|
||||||
| `internal/domain/git/` | Git binary wrapper — safe exec patterns |
|
| `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/ui/tokens.ts` | Design token source of truth |
|
||||||
| `frontend/src/components/AppShell.tsx` | Root layout wrapper |
|
| `frontend/src/components/AppShell.tsx` | Root layout wrapper |
|
||||||
| `frontend/src/components/Sidebar.tsx` | 3-state navigation sidebar |
|
| `frontend/src/api/client.ts` | Typed API client with CSRF handling |
|
||||||
| `frontend/src/pages/` | All 20 route-level pages |
|
| `.env.example` | All environment variables with documentation |
|
||||||
| `frontend/src/api/` | Typed API client |
|
|
||||||
| `.env.example` | All required environment variables |
|
|
||||||
| `CLAUDE.md` | Developer guide (rules overlap with this file — CLAUDE.md takes precedence on conflicts) |
|
| `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
|
```bash
|
||||||
cp .env.example .env # fill SESSION_SECRET and CSRF_SECRET
|
cp .env.example .env # fill SESSION_SECRET and CSRF_SECRET
|
||||||
make docker-up # PostgreSQL via Docker Compose
|
make docker-up # PostgreSQL + NATS via Docker Compose
|
||||||
make migrate # run XORM migrations
|
make migrate # run XORM migrations (currently 020)
|
||||||
make dev # Go :8080 + Vite :5173
|
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
@@ -9,63 +9,280 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### In Progress — Phase 3C (Workspaces + Secret management hierarchy)
|
### Planned — Phase 5 (Deployment Promotions + Rollback Visualization)
|
||||||
- `Workspace` model — named collaborative namespace (handle, displayName, description, avatarUrl)
|
- Deployment promotion workflows (manual + automated)
|
||||||
- `WorkspaceMember` model — user membership with owner/admin/member roles
|
- Rollback visualization and timeline
|
||||||
- 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
|
## [1.0.0] — 2026-05-13
|
||||||
- WorkspacesPage, WorkspacePage, WorkspaceSettingsPage (settings + members)
|
|
||||||
- Workspace switcher in sidebar header
|
Phase 4 complete. SBOM generation, secret scanning, dependency vulnerability scanning, signed artifacts, and OCI registry are operational.
|
||||||
- Create repo: workspace owner selector
|
|
||||||
- RepoSecretsPage — write-only secret management per repo and per environment
|
### 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
|
- 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
|
- 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)
|
## [0.4.0] — 2026-05-11
|
||||||
- `PipelinesPage` — real cross-repo runs feed with status filter tabs
|
|
||||||
|
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`
|
- `RepoPipelinesPage` — repo-scoped runs list at `/repos/:owner/:repo/pipelines`
|
||||||
- `PipelineRunPage` — run detail with topological DAG visualization + step log viewer
|
- `PipelineRunPage` — run detail with topological DAG visualization using real `PipelineJob[]` +
|
||||||
- `PipelineWaterfall` — rewritten to accept real `PipelineJob[]` data with `needs` graph
|
`needs` graph; step log viewer (collapsible per step, ANSI color, auto-scroll with lock toggle)
|
||||||
- Dashboard CI widget — live recent runs replacing "coming soon" placeholder
|
- `PipelineWaterfall` — rewritten to accept live job data instead of static mock stages
|
||||||
- Command palette — pipeline run results + Pipelines quick-nav
|
- `GET /api/v1/pipelines/runs` — cross-repo recent runs for the dashboard
|
||||||
- `GET /api/v1/pipelines/runs` — cross-repo recent runs endpoint
|
|
||||||
- Dashboard `recentRuns[]` field added
|
|
||||||
|
|
||||||
### Planned — Phase 3 (GitOps + Observability + Federation)
|
### Added — Dashboard CI Command Center
|
||||||
- GitOps controller with reconciliation loops
|
- Dashboard CI widget replaced "coming soon" with live recent pipeline runs
|
||||||
- Environment model + deployment tracking
|
- Dashboard `recentRuns[]` field added to the `/api/v1/dashboard` response
|
||||||
- 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)
|
|
||||||
|
|
||||||
### Planned — Phase 4
|
### Added — Command Palette Wiring
|
||||||
- AI diagnostics (pipeline failure root-cause analysis)
|
- Pipeline run results surfaced in command palette results
|
||||||
- Signed artifacts (Sigstore/Cosign)
|
- "Pipelines" quick-nav action
|
||||||
- OCI package registry
|
|
||||||
- Secret and dependency vulnerability scanning
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,38 +292,35 @@ Phase 2B complete. Full CI/CD execution backend operational.
|
|||||||
|
|
||||||
### Added — CI Orchestrator (`internal/domain/ci/`)
|
### Added — CI Orchestrator (`internal/domain/ci/`)
|
||||||
- DAG-based pipeline orchestrator (`orchestrator.go`): subscribes to NATS `push.received`,
|
- DAG-based pipeline orchestrator (`orchestrator.go`): subscribes to NATS `push.received`,
|
||||||
parses `.forgebucket/workflows/*.yml`, creates `PipelineRun`/`PipelineJob`/`PipelineStep`
|
parses `.forgebucket/workflows/*.yml`, creates `PipelineRun/Job/Step` records, advances
|
||||||
records, advances DAG on `job.completed`/`job.failed`, recovers stale runs on startup
|
DAG on `job.completed/failed`, recovers stale runs on startup
|
||||||
- Docker executor (`executor.go`): runs steps in isolated containers (`docker run --rm`),
|
- Docker executor (`executor.go`): steps run in isolated containers (`docker run --rm`),
|
||||||
streams logs to DB and NATS via `pipeline.log` subject, handles `git archive` workspace extraction
|
logs stream to DB and NATS via `pipeline.log`, workspace extracted via `git archive`
|
||||||
- Runner manager (`runner_manager.go`): semaphore-limited concurrent job dispatch (default 4),
|
- Runner manager (`runner_manager.go`): semaphore-limited (default 4 concurrent),
|
||||||
subscribes to `job.queued`, calls executor when Docker is available
|
subscribes to `job.queued`, skips gracefully if Docker is unavailable
|
||||||
- DAG engine (`dag.go`): full topological sort (`TopoSort`) and `ReadyJobs` for dependency resolution
|
- DAG engine (`dag.go`): `TopoSort`, `ReadyJobs`
|
||||||
- Workflow parser (`parser.go`): reads `.forgebucket/workflows/*.yml` from git ref,
|
- Workflow parser (`parser.go`): `.forgebucket/workflows/*.yml` from git ref,
|
||||||
`MatchesPushTrigger` with glob pattern support
|
`MatchesPushTrigger` with glob branch patterns; `StringOrSlice` YAML unmarshaler
|
||||||
- CI types (`types.go`): `WorkflowFile`, `WorkflowJob`, `WorkflowStep`, YAML `StringOrSlice` unmarshaler
|
|
||||||
|
|
||||||
### Added — CI API Handlers
|
### Added — CI API Handlers
|
||||||
- `GET /api/v1/repos/:owner/:repo/pipelines` — list pipeline definitions
|
- `GET /api/v1/repos/:owner/:repo/pipelines` — pipeline definitions
|
||||||
- `GET /api/v1/repos/:owner/:repo/runs` — list pipeline runs (most recent first, limit 30)
|
- `GET /api/v1/repos/:owner/:repo/runs` — pipeline runs (newest first)
|
||||||
- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with full job + step tree
|
- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with 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/cancel`
|
||||||
- `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry` — re-queue failed/cancelled job
|
- `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry`
|
||||||
- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step-level log chunks
|
- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step log chunks
|
||||||
- `GET /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — list artifacts for a run
|
- `GET/POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts`
|
||||||
- `POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — upload artifact (multipart, 512 MB max)
|
- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — path-traversal guarded
|
||||||
- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — artifact download with path traversal guard
|
- `GET/POST /api/v1/admin/runners` — runner list + registration (admin-only, bcrypt token)
|
||||||
- `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)
|
|
||||||
|
|
||||||
### Added — Database Models (migration `009_ci`)
|
### Added — Database Models (migration `009_ci`)
|
||||||
- `Pipeline` — workflow definition record (name, filePath, repoId)
|
- `Pipeline`, `PipelineRun`, `PipelineJob`, `PipelineStep`, `PipelineStepLog`
|
||||||
- `PipelineRun` — execution record (triggerRef, triggerSha, triggeredBy, status, startedAt, finishedAt)
|
- `Runner` (name, labels, status, tokenHash, lastSeenAt)
|
||||||
- `PipelineJob` — single DAG node (name, image, needs JSON, status, timing)
|
- `Artifact` (runId, repoId, name, storagePath, size, contentType)
|
||||||
- `PipelineStep` — single command within a job (seq, runCmd, usesAction, exitCode, timing)
|
|
||||||
- `PipelineStepLog` — append-only log chunk storage (stepId, chunkIndex, content)
|
### Changed — Git HTTP handler
|
||||||
- `Runner` — registered execution backend (name, labels, status, tokenHash, lastSeenAt)
|
- `parseAndCheckBody` replaces `checkProtectionsFromBody` — now also returns parsed
|
||||||
- `Artifact` — build artifact (runId, repoId, name, storagePath, size, contentType)
|
`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/`)
|
### Added — NATS Event Bus (`internal/events/`)
|
||||||
- `EventBus` interface: `Publish`, `Subscribe`, `Close`
|
- `EventBus` interface: `Publish`, `Subscribe`, `Close`
|
||||||
- `NATSBus`: NATS-backed implementation with auto-reconnect, max-reconnect disabled
|
- `NATSBus`: NATS-backed with auto-reconnect; `NoOpBus` fallback when `NATS_URL` unset
|
||||||
- `NoOpBus`: silent fallback when `NATS_URL` is not configured (app fully functional without NATS)
|
- `New(url)` factory: returns `NATSBus` or `NoOpBus`
|
||||||
- `New(url)` factory: returns `NATSBus` if URL is set, `NoOpBus` otherwise
|
- 40+ event subjects in `subjects.go` covering repo, push, PR, issue, pipeline, job,
|
||||||
- Event subjects defined in `subjects.go`:
|
deployment, environment, and audit namespaces
|
||||||
- `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`
|
|
||||||
|
|
||||||
### Added — WebSocket Hub (`internal/api/handlers/ws.go`)
|
### Added — WebSocket Hub
|
||||||
- `GET /ws` — upgrades HTTP to WebSocket (nhooyr.io/websocket)
|
- `GET /ws` — NATS wildcard subscription (`>`) fans all events to connected clients as JSON
|
||||||
- Subscribes to all NATS subjects on connect, fans events to the client as JSON
|
- `{ subject, payload }` envelope format
|
||||||
- Optional session auth (`auth.Optional` middleware) — works for guests too
|
- Goroutine per client with buffered send channel (64 events); slow clients drop events
|
||||||
- Phase 2B note: per-user event filtering is a planned upgrade
|
|
||||||
|
|
||||||
### Added — Audit Log
|
### Added — Audit Log (migration `008_audit_log`)
|
||||||
- `AuditLog` model (migration `008_audit_log`): actor, method, path, statusCode, requestBody, ipAddr, timestamp
|
- `AuditLog` model: actorId, actorName, method, path, statusCode, ipAddress, userAgent
|
||||||
- `AuditLog` middleware: records every authenticated request to the DB and publishes `audit.event`
|
- Middleware records every POST/PUT/PATCH/DELETE in the protected route group
|
||||||
- `GET /api/v1/audit` — paginated audit log query (admin-only, filterable by actor/method/time range)
|
- Writes DB row + publishes `audit.event` asynchronously (never blocks the response)
|
||||||
|
- `GET /api/v1/audit` — paginated, filterable by actor/method/since (admin-only)
|
||||||
### 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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [0.1.0] — 2026-05-11
|
## [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
|
### Added — Authentication & Security
|
||||||
- User registration and login with secure session cookies
|
- User registration and login with secure session cookies
|
||||||
- CSRF protection on all mutating routes via `X-CSRF-Token` header
|
- CSRF protection via double-submit cookie pattern (`X-CSRF-Token`)
|
||||||
- Middleware chain: Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → Handler
|
|
||||||
- SSH key management per user
|
- SSH key management per user
|
||||||
- OIDC / OAuth2 optional integration (configurable via env)
|
- OIDC / OAuth2 optional integration
|
||||||
- Scoped access tokens with optional expiration dates
|
- Scoped access tokens with optional expiration
|
||||||
- Repository deploy keys (read-only or read-write HTTP tokens)
|
- Repository deploy keys (read-only or read-write)
|
||||||
- ENV-driven config with fail-fast validation on missing secrets
|
- ENV-driven config with fail-fast on missing secrets
|
||||||
|
|
||||||
### Added — Git Hosting
|
### Added — Git Hosting
|
||||||
- Smart HTTP transport (git clone, push, pull over HTTP)
|
- Smart HTTP transport (clone, push, pull over HTTP)
|
||||||
- AGit protocol support (`refs/for/` push for instant PR creation without branch switching)
|
- AGit protocol (`refs/for/` push for instant PR creation)
|
||||||
- Branch management (list, create, delete, default branch configuration)
|
- Branch management, commit log, diff viewing
|
||||||
- Commit log and diff viewing
|
- Git LFS per-repository (configurable file size limits)
|
||||||
- Git LFS per-repository (configurable file size limits, locking)
|
- Branch protection rules (force-push blocking)
|
||||||
- Branch protection rules (force-push blocking, required reviews)
|
|
||||||
- Repository visibility (public / private)
|
- Repository visibility (public / private)
|
||||||
|
|
||||||
### Added — Collaboration
|
### Added — Collaboration
|
||||||
- Pull requests (open / merged / closed states) with author tracking
|
- Pull requests (open / merged / closed) with author tracking
|
||||||
- Issues (open / closed)
|
- Issues (open / closed)
|
||||||
- Reviewer assignment (default reviewer per repo, per-PR reviewer assignment)
|
- Reviewer assignment (default reviewer per repo, per-PR overrides)
|
||||||
- Merge strategy selection per repository (merge commit / squash / rebase)
|
- Merge strategy selection per repository (merge / squash / rebase)
|
||||||
- Branching model configuration (feature / bugfix / release / hotfix prefixes)
|
- Branching model configuration (feature / bugfix / release / hotfix prefixes)
|
||||||
- PR default description templates (per-repo)
|
- PR default description templates + excluded-files configuration
|
||||||
- Excluded files from diffs (glob pattern configuration)
|
|
||||||
- Webhook system with event filtering (push, pull_request, issue)
|
- 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
|
### Added — Frontend SPA
|
||||||
- React 18 + TypeScript + Vite, embedded into Go binary via `//go:embed`
|
- React 18 + TypeScript + Vite, embedded into Go binary via `//go:embed`
|
||||||
- 20 route-level pages: Login, Register, Dashboard, Repos, CreateRepo, ImportRepo, Repo,
|
- 20 route-level pages covering auth, dashboard, repos, code, PRs, issues, and settings
|
||||||
RepoSettings, Blob, Commits, Branches, RepoIssues, RepoPRs, CreatePR, PRDetail, Starred,
|
|
||||||
PRs (cross-repo), Pipelines (placeholder), Explore, Profile, Settings
|
|
||||||
- AppShell layout wrapper for all authenticated pages
|
|
||||||
- Triple-state sidebar: expanded (320px) / collapsed (56px) / mobile bottom bar
|
- Triple-state sidebar: expanded (320px) / collapsed (56px) / mobile bottom bar
|
||||||
- Mobile-first responsive design (375px → 1440px)
|
- Mobile-first responsive design (375px → 1440px)
|
||||||
- DiffViewer: side-by-side and unified views with syntax highlighting
|
- DiffViewer (side-by-side + unified), MobileComment (bottom-sheet), TreeBrowser
|
||||||
- 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
|
|
||||||
|
|
||||||
### Added — Design System
|
### Added — Design System
|
||||||
- Custom semantic token palette in `frontend/src/ui/tokens.ts`
|
- Custom semantic token palette in `frontend/src/ui/tokens.ts`
|
||||||
- Full dark/light mode support via Tailwind CSS v4 `@variant dark`
|
- Full dark/light mode via Tailwind CSS v4 `@variant dark`
|
||||||
- Brand colors: `#0052CC` (light) / `#3B82F6` (dark)
|
- 8px grid system; 44px minimum touch targets (WCAG 2.5.5)
|
||||||
- 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 3–8px, full 9999px)
|
|
||||||
- System font stack (Segoe UI, Roboto, sans-serif)
|
- System font stack (Segoe UI, Roboto, sans-serif)
|
||||||
|
|
||||||
### Added — Infrastructure
|
### Added — Infrastructure
|
||||||
- PostgreSQL + XORM with 7 migration files covering: users, repositories, issues, SSH keys,
|
- PostgreSQL + XORM with migrations 001–007
|
||||||
access tokens, deploy keys, workflows, and LFS settings
|
- ActivityPub actor data model (FederationActor) — data layer only
|
||||||
- ActivityPub actor data model (FederationActor with inbox/outbox URLs and RSA key pairs) — data layer only
|
- Docker Compose for local PostgreSQL + NATS
|
||||||
- Docker Compose setup for local PostgreSQL + NATS
|
- Makefile: dev, build, migrate, test, lint, docker-up
|
||||||
- Makefile targets: dev, build, migrate, test, lint, docker-up
|
|
||||||
- WebSockets foundation for live logs and notifications
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[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.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.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
|
[0.1.0]: https://github.com/forgeo/forgebucket/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -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.
|
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 |
|
| OIDC / OAuth2 (optional) | Done |
|
||||||
| Access tokens (scoped, expiring) | Done |
|
| Access tokens (scoped, expiring) | Done |
|
||||||
| Deploy keys | Done |
|
| Deploy keys | Done |
|
||||||
| Audit log | Done |
|
| Audit log (admin-only, filterable) | Done |
|
||||||
|
| Workspaces (multi-tenant namespaces) | Done |
|
||||||
|
|
||||||
### Git Hosting
|
### Git Hosting
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
@@ -59,44 +60,62 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
|
|||||||
### CI/CD
|
### CI/CD
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| CI orchestrator (DAG pipeline execution) | Done (Phase 2B) |
|
| NATS event bus + WebSocket live push | Done |
|
||||||
| Runner manager (Docker backend) | Done (Phase 2B) |
|
| CI orchestrator (DAG pipeline execution) | Done |
|
||||||
| Build artifact storage | Done (Phase 2B) |
|
| Runner manager (Docker backend) | Done |
|
||||||
| Pipeline cancellation + job retry | Done (Phase 2B) |
|
| Build artifact storage + download | Done |
|
||||||
| NATS event bus + WebSocket live push | Done (Phase 2A) |
|
| Pipeline cancellation + job retry | Done |
|
||||||
| Pipeline DAG visualization (frontend) | Done (Phase 2C) |
|
| Pipeline log streaming (per-step, NATS) | Done |
|
||||||
| Dashboard CI command center | Done (Phase 2C) |
|
| Pipeline DAG visualization (frontend) | Done |
|
||||||
| Pipeline log viewer (per-step, collapsible) | Done (Phase 2C) |
|
| Dashboard CI command center | Done |
|
||||||
| Kubernetes / Firecracker runner backends | Planned (Phase 2D) |
|
| Pipeline log viewer (collapsible, per-step) | Done |
|
||||||
| Forgejo Actions gRPC integration | Planned |
|
| 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 |
|
| Matrix builds + reusable workflow templates | Planned |
|
||||||
| Flaky test detection | Planned |
|
| Flaky test detection | Planned |
|
||||||
|
|
||||||
### GitOps + Environments
|
### Environments + GitOps
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Environment model + deployment tracking | **In progress (Phase 3A)** |
|
| Environment model + deployment tracking | Done |
|
||||||
| Unified operational timeline | Planned (Phase 3B) |
|
| Deployment status lifecycle API | Done |
|
||||||
| Secret management hierarchy | Planned (Phase 3C) |
|
| Unified operational timeline | Done |
|
||||||
| GitOps controller + drift detection | Planned (Phase 3D) |
|
| Secret management (Global → Workspace → Repo → Env) | Done |
|
||||||
| Deployment promotion workflows | Planned (Phase 3D) |
|
| GitOps controller (drift detection + auto-sync) | Done |
|
||||||
| Rollback visualization | Planned (Phase 3D) |
|
| Deployment promotion workflows | Planned |
|
||||||
| Canary / blue-green support | Planned (Phase 3D) |
|
| Rollback visualization | Planned |
|
||||||
|
| Canary / blue-green support | Planned |
|
||||||
|
|
||||||
### Observability + Security
|
### Observability + Security
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Prometheus endpoint + health sparklines | Planned (Phase 3E) |
|
| `GET /health` — structured DB + NATS liveness check | Done |
|
||||||
| Secret scanning | Planned (Phase 4) |
|
| `GET /metrics` — Prometheus endpoint (HTTP + platform metrics) | Done |
|
||||||
| Dependency scanning | Planned (Phase 4) |
|
| HTTP instrumentation middleware (latency histogram, request counter) | Done |
|
||||||
| Signed artifacts (Sigstore/Cosign) | Planned (Phase 4) |
|
| 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
|
### Federation
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| ActivityPub actor model | Done (data layer) |
|
| ActivityPub actor model | Done |
|
||||||
| Federation handlers / inbox / outbox | Planned (Phase 3F) |
|
| WebFinger (`/.well-known/webfinger`) | Done |
|
||||||
| Cross-instance pull requests | Planned (Phase 3F) |
|
| 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.
|
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
|
ForgeBucket
|
||||||
├── API Gateway (Chi router, internal/api/)
|
├── API Gateway (Chi router — internal/api/router.go)
|
||||||
├── Auth Service (sessions, CSRF, OIDC — internal/api/handlers/)
|
├── Auth Service (sessions, CSRF, OIDC — internal/api/handlers/)
|
||||||
├── Repository Service (git HTTP, branches, LFS — internal/domain/git/)
|
├── Repository Service (git HTTP, branches, LFS — internal/domain/git/)
|
||||||
├── Pull Request Service (PRs, reviews, merge — internal/api/handlers/)
|
├── Pull Request Service (PRs, reviews, merge — internal/api/handlers/)
|
||||||
├── Issue Service (issues, labels — internal/api/handlers/)
|
├── Issue Service (issues — internal/api/handlers/)
|
||||||
├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/) ← Phase 2B done
|
├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/)
|
||||||
├── Event Bus (NATS core, NoOp fallback — internal/events/) ← Phase 2A done
|
├── GitOps Controller (drift detection, auto-sync — internal/domain/gitops/)
|
||||||
├── Federation Layer (ActivityPub actors — internal/domain/federation/) ← Phase 3F stub
|
├── Observability (Prometheus metrics, health — internal/observability/)
|
||||||
├── Secret Manager (env-based, scoped tokens — internal/config/)
|
├── Environment Service (environments, deployments — internal/api/handlers/environment.go)
|
||||||
├── Database (PostgreSQL + XORM — internal/models/)
|
├── Secret Manager (scoped AES-256-GCM — internal/api/handlers/secret.go)
|
||||||
└── Web Frontend (React 18 + TypeScript, embedded via //go:embed — web/)
|
├── 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):**
|
**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+ |
|
| Language | Go 1.21+ |
|
||||||
| Router | Chi |
|
| Router | Chi |
|
||||||
| ORM / Migrations | XORM + PostgreSQL |
|
| ORM / Migrations | XORM + PostgreSQL |
|
||||||
| Event bus | NATS (core; JetStream planned for Phase 2B durability) |
|
| Event bus | NATS core (`github.com/nats-io/nats.go`) |
|
||||||
| Real-time | WebSockets (nhooyr.io/websocket) |
|
| Real-time | WebSockets (`nhooyr.io/websocket`) |
|
||||||
| CI execution | Docker (`docker run --rm`) |
|
| CI execution | Docker (`docker run --rm` via `exec.Command`) |
|
||||||
| Frontend framework | React 18 + TypeScript |
|
| Frontend framework | React 18 + TypeScript |
|
||||||
| Build tool | Vite |
|
| Build tool | Vite |
|
||||||
| Styling | Tailwind CSS v4 |
|
| Styling | Tailwind CSS v4 |
|
||||||
|
| YAML parsing | `gopkg.in/yaml.v3` (workflow definitions) |
|
||||||
| Code editing | CodeMirror |
|
| Code editing | CodeMirror |
|
||||||
| Container | Docker Compose (dev) |
|
| 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 |
|
| 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`) |
|
| `SESSION_SECRET` | Yes | Session signing key, ≥ 32 chars (`openssl rand -hex 32`) |
|
||||||
| `CSRF_SECRET` | Yes | CSRF key, exactly 32 chars (`openssl rand -hex 16`) |
|
| `CSRF_SECRET` | Yes | CSRF key, exactly 32 chars (`openssl rand -hex 16`) |
|
||||||
| `PORT` | No | HTTP port, default `8080` |
|
| `PORT` | No | HTTP port, default `8080` |
|
||||||
| `REPO_ROOT` | Yes | Absolute path for bare git repository storage |
|
| `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_URL` | Yes | Public URL of this instance (no trailing slash) |
|
||||||
| `INSTANCE_NAME` | No | Display name, default `ForgeBucket` |
|
| `INSTANCE_NAME` | No | Display name, default `ForgeBucket` |
|
||||||
| `OIDC_ISSUER` | No | OIDC provider URL |
|
| `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 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | Done |
|
||||||
| Phase 3A | Environment model + deployment tracking | Done |
|
| Phase 3A | Environment model + deployment tracking | Done |
|
||||||
| Phase 3B | Unified operational timeline | Done |
|
| Phase 3B | Unified operational timeline | Done |
|
||||||
| Phase 3C | Workspaces + secret management hierarchy | **In progress** |
|
| Phase 3C | Workspaces + secret management hierarchy (Global → Workspace → Repo → Env) | Done |
|
||||||
| Phase 3D–F | GitOps/drift, federation, observability | Planned |
|
| Phase 3D | GitOps controller + drift detection + auto-sync | Done |
|
||||||
| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned |
|
| 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
@@ -19,7 +19,14 @@ import (
|
|||||||
"github.com/forgeo/forgebucket/internal/db"
|
"github.com/forgeo/forgebucket/internal/db"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/ci"
|
"github.com/forgeo/forgebucket/internal/domain/ci"
|
||||||
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
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/events"
|
||||||
|
"github.com/forgeo/forgebucket/internal/observability"
|
||||||
"github.com/forgeo/forgebucket/internal/models/migrations"
|
"github.com/forgeo/forgebucket/internal/models/migrations"
|
||||||
"github.com/forgeo/forgebucket/web"
|
"github.com/forgeo/forgebucket/web"
|
||||||
)
|
)
|
||||||
@@ -48,6 +55,12 @@ func main() {
|
|||||||
log.Fatalf("artifact root: %v", err)
|
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)
|
bus, err := events.New(cfg.NATSUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("events: %v", err)
|
log.Fatalf("events: %v", err)
|
||||||
@@ -67,13 +80,45 @@ func main() {
|
|||||||
ciCtx, ciCancel := context.WithCancel(context.Background())
|
ciCtx, ciCancel := context.WithCancel(context.Background())
|
||||||
defer ciCancel()
|
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)
|
go orchestrator.Start(ciCtx)
|
||||||
|
|
||||||
runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4)
|
runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4)
|
||||||
go runnerMgr.Start(ciCtx)
|
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{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%s", cfg.Port),
|
Addr: fmt.Sprintf(":%s", cfg.Port),
|
||||||
|
|||||||
+22
-5
@@ -1,21 +1,36 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18.3
|
image: postgres:18.3
|
||||||
|
container_name: fb-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: forgebucket
|
POSTGRES_DB: forgebucket
|
||||||
POSTGRES_USER: forgebucket
|
POSTGRES_USER: forgebucket
|
||||||
POSTGRES_PASSWORD: forgebucket
|
POSTGRES_PASSWORD: forgebucket
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql
|
- fb_pg_data:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U forgebucket"]
|
test: ["CMD-SHELL", "pg_isready -U forgebucket"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
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:
|
app:
|
||||||
build: .
|
build: .
|
||||||
|
container_name: fb-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -26,8 +41,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/var/lib/forgebucket/repos
|
- fb_repo_data:/tmp/forgebucket/repos
|
||||||
|
- fb_oci_data:/tmp/forgebucket/oci
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
fb_pg_data:
|
||||||
repo_data:
|
fb_repo_data:
|
||||||
|
fb_oci_data:
|
||||||
|
|||||||
+9
-6
@@ -1,16 +1,14 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
# Dev: only PostgreSQL runs here. Run the Go server locally with `make dev`.
|
# Dev: only PostgreSQL runs here. Run the Go server locally with `make dev`.
|
||||||
# Production: docker compose -f docker-compose.prod.yml up
|
# Production: docker compose -f docker-compose.prod.yml up
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nats:
|
nats:
|
||||||
image: nats:2-alpine
|
image: mirror.gcr.io/nats:2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["-js", "-m", "8222"]
|
command: ["-js", "-m", "8222"]
|
||||||
ports:
|
ports:
|
||||||
- "4222:4222" # client connections
|
- "4222:4222" # client connections
|
||||||
- "8222:8222" # monitoring HTTP
|
- "8222:8222" # monitoring HTTP
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -18,7 +16,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: mirror.gcr.io/postgres:18
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: forgebucket
|
POSTGRES_DB: forgebucket
|
||||||
@@ -34,5 +32,10 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
dbgate:
|
||||||
|
image: dbgate/dbgate
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
|
|||||||
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
|
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
|
||||||
const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
|
const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
|
||||||
const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage'))
|
const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage'))
|
||||||
|
const RepoSecurityPage = lazy(() => import('./pages/RepoSecurityPage'))
|
||||||
const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage'))
|
const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage'))
|
||||||
const WorkspacePage = lazy(() => import('./pages/WorkspacePage'))
|
const WorkspacePage = lazy(() => import('./pages/WorkspacePage'))
|
||||||
const WorkspaceSettingsPage = lazy(() => import('./pages/WorkspaceSettingsPage'))
|
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/environments" element={<S><EnvironmentsPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/timeline" element={<S><RepoTimelinePage /></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/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="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
|
||||||
|
|
||||||
<Route path="starred" element={<S><StarredPage /></S>} />
|
<Route path="starred" element={<S><StarredPage /></S>} />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines'
|
import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines'
|
||||||
|
import { useRunSBOM, getRunSBOMDocumentURL, useGenerateSBOM } from '../api/queries/sbom'
|
||||||
import { Skeleton } from '../ui/Skeleton'
|
import { Skeleton } from '../ui/Skeleton'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import type { PipelineJob, PipelineStep, RunStatus } from '../types/api'
|
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`
|
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 {
|
function shortRef(ref: string): string {
|
||||||
return ref.replace('refs/heads/', '').replace('refs/tags/', '')
|
return ref.replace('refs/heads/', '').replace('refs/tags/', '')
|
||||||
}
|
}
|
||||||
@@ -172,7 +254,7 @@ function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] {
|
|||||||
const job = nameToJob.get(name)
|
const job = nameToJob.get(name)
|
||||||
if (!job) return 0
|
if (!job) return 0
|
||||||
let needs: string[] = []
|
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))))
|
const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited))))
|
||||||
depth.set(name, d)
|
depth.set(name, d)
|
||||||
return d
|
return d
|
||||||
@@ -357,6 +439,11 @@ export default function PipelineRunPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* SBOM section */}
|
||||||
|
{!isLoading && run && (
|
||||||
|
<SBOMSection owner={owner} repo={repo} runId={runIdNum} runStatus={run.status as RunStatus} triggerSha={run.triggerSha} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* DAG + log viewer */}
|
{/* DAG + log viewer */}
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
|
||||||
|
|||||||
@@ -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}/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}/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}/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>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -342,3 +342,50 @@ export interface ApiError {
|
|||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
status: 'ok'
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,22 +8,31 @@ require (
|
|||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.12.3
|
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
|
golang.org/x/crypto v0.50.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
nhooyr.io/websocket v1.8.17
|
nhooyr.io/websocket v1.8.17
|
||||||
xorm.io/xorm v1.3.11
|
xorm.io/xorm v1.3.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // 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/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // 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
|
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/sys v0.43.0 // indirect
|
||||||
golang.org/x/tools 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
|
xorm.io/builder v0.3.13 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
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 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/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 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
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 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||||
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
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=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
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 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
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.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 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
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 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
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 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 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/domain/signing"
|
||||||
"github.com/forgeo/forgebucket/internal/models"
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ArtifactHandler struct {
|
type ArtifactHandler struct {
|
||||||
db *xorm.Engine
|
db *xorm.Engine
|
||||||
artifactRoot string
|
artifactRoot string
|
||||||
|
keys *signing.KeyStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArtifactHandler(db *xorm.Engine, artifactRoot string) *ArtifactHandler {
|
func NewArtifactHandler(db *xorm.Engine, artifactRoot string, keys *signing.KeyStore) *ArtifactHandler {
|
||||||
return &ArtifactHandler{db: db, artifactRoot: artifactRoot}
|
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) {
|
func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
repoID, runID, ok := h.resolveRunIDs(w, r)
|
repoID, runID, ok := h.resolveRunIDs(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -40,8 +44,8 @@ func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, artifacts)
|
jsonOK(w, artifacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload accepts a multipart file upload and stores it as an artifact.
|
// Upload accepts a multipart file upload, stores it as an artifact, and
|
||||||
// Callers must provide a valid Bearer access token with write scope (runner auth).
|
// immediately signs it using the server's signing key.
|
||||||
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
repoID, runID, ok := h.resolveRunIDs(w, r)
|
repoID, runID, ok := h.resolveRunIDs(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -87,8 +91,13 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer dst.Close()
|
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 {
|
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)
|
jsonError(w, "could not write file", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,7 +115,7 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
RepoID: repoID,
|
RepoID: repoID,
|
||||||
Name: name,
|
Name: name,
|
||||||
StoragePath: relPath,
|
StoragePath: relPath,
|
||||||
Size: size,
|
Size: int64(len(content)),
|
||||||
ContentType: ct,
|
ContentType: ct,
|
||||||
}
|
}
|
||||||
if _, err := h.db.Insert(artifact); err != nil {
|
if _, err := h.db.Insert(artifact); err != nil {
|
||||||
@@ -114,10 +123,38 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sign the artifact and persist the bundle.
|
||||||
|
go h.signArtifact(artifact.ID, name, content)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
jsonOK(w, artifact)
|
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.
|
// Download streams the artifact file to the client.
|
||||||
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||||
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
|
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))
|
fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath))
|
||||||
// Ensure the resolved path stays within artifactRoot (traversal guard).
|
|
||||||
if !isUnder(h.artifactRoot, fullPath) {
|
if !isUnder(h.artifactRoot, fullPath) {
|
||||||
jsonError(w, "forbidden", http.StatusForbidden)
|
jsonError(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
@@ -155,17 +191,66 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
io.Copy(w, f) //nolint:errcheck
|
io.Copy(w, f) //nolint:errcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) {
|
// GetSignature returns the full signature bundle JSON for an artifact.
|
||||||
owner := chi.URLParam(r, "owner")
|
// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/signature
|
||||||
repoName := chi.URLParam(r, "repo")
|
func (h *ArtifactHandler) GetSignature(w http.ResponseWriter, r *http.Request) {
|
||||||
var u models.User
|
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
|
||||||
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
|
if err != nil {
|
||||||
jsonError(w, "repository not found", http.StatusNotFound)
|
jsonError(w, "invalid artifact ID", http.StatusBadRequest)
|
||||||
return 0, 0, false
|
return
|
||||||
}
|
}
|
||||||
var repo models.Repository
|
|
||||||
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
|
var sig models.ArtifactSignature
|
||||||
jsonError(w, "repository not found", http.StatusNotFound)
|
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
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
|
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)
|
jsonError(w, "invalid run ID", http.StatusBadRequest)
|
||||||
return 0, 0, false
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
return repo.ID, runID, true
|
return rID, runID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isUnder(root, path string) bool {
|
func isUnder(root, path string) bool {
|
||||||
|
|||||||
@@ -323,19 +323,7 @@ func (h *EnvironmentHandler) UpdateDeploymentStatus(w http.ResponseWriter, r *ht
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
owner := chi.URLParam(r, "owner")
|
return resolveRepoID(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
|
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
|
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) {
|
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,
|
DeploymentID: d.ID,
|
||||||
EnvID: env.ID,
|
EnvID: env.ID,
|
||||||
EnvName: env.Name,
|
EnvName: env.Name,
|
||||||
RepoID: d.RepoID,
|
RepoID: d.RepoID,
|
||||||
SHA: d.SHA,
|
SHA: d.SHA,
|
||||||
Ref: d.Ref,
|
Ref: d.Ref,
|
||||||
Status: d.Status,
|
Status: string(d.Status),
|
||||||
TriggeredBy: d.TriggeredBy,
|
TriggeredBy: d.TriggeredBy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) {
|
func (h *IssueHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
return resolveRepoID(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IssueHandler) lookupIssue(w http.ResponseWriter, r *http.Request) (*models.Issue, bool) {
|
func (h *IssueHandler) lookupIssue(w http.ResponseWriter, r *http.Request) (*models.Issue, bool) {
|
||||||
|
|||||||
@@ -48,19 +48,13 @@ type deployKeyResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DeployKeyHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
|
func (h *DeployKeyHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
repo, ok := resolveRepo(h.db, w, r)
|
||||||
repoName := chi.URLParam(r, "repo")
|
if !ok {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
var owner models.User
|
var owner models.User
|
||||||
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
|
h.db.ID(repo.OwnerID).Get(&owner)
|
||||||
jsonError(w, "repository not found", http.StatusNotFound)
|
return repo, &owner, true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DeployKeyHandler) canManage(repo *models.Repository, callerID int64) bool {
|
func (h *DeployKeyHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
"github.com/forgeo/forgebucket/internal/api/middleware"
|
"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 NewLFSHandler(db *xorm.Engine) *LFSHandler { return &LFSHandler{db: db} }
|
||||||
|
|
||||||
func (h *LFSHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
func (h *LFSHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
return resolveRepo(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *LFSHandler) canManage(repo *models.Repository, callerID int64) bool {
|
func (h *LFSHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||||
|
|||||||
@@ -28,22 +28,15 @@ type memberResponse struct {
|
|||||||
AddedAt string `json:"addedAt"`
|
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) {
|
func (h *MemberHandler) lookupRepoAndOwner(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
repo, ok := resolveRepo(h.db, w, r)
|
||||||
repoName := chi.URLParam(r, "repo")
|
if !ok {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
var owner models.User
|
var owner models.User
|
||||||
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
|
h.db.ID(repo.OwnerID).Get(&owner)
|
||||||
jsonError(w, "repository not found", http.StatusNotFound)
|
return repo, &owner, true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// callerCanManage returns true if callerID is the repo owner or has admin permission.
|
// callerCanManage returns true if callerID is the repo owner or has admin permission.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -247,19 +247,7 @@ func (h *PipelineHandler) RetryJob(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *PipelineHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
func (h *PipelineHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
owner := chi.URLParam(r, "owner")
|
return resolveRepoID(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PipelineHandler) lookupRun(w http.ResponseWriter, r *http.Request) (*models.PipelineRun, bool) {
|
func (h *PipelineHandler) lookupRun(w http.ResponseWriter, r *http.Request) (*models.PipelineRun, bool) {
|
||||||
|
|||||||
@@ -18,29 +18,7 @@ func NewPRSettingsHandler(db *xorm.Engine) *PRSettingsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PRSettingsHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
func (h *PRSettingsHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
return resolveRepo(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PRSettingsHandler) canManage(repo *models.Repository, callerID int64) bool {
|
func (h *PRSettingsHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||||
|
|||||||
@@ -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) {
|
func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
return resolveRepoID(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) {
|
func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -242,29 +242,7 @@ func ResolveSecretsForRun(db *xorm.Engine, repoID, workspaceID, envID int64, ses
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *SecretHandler) resolveRepoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
func (h *SecretHandler) resolveRepoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
owner := chi.URLParam(r, "owner")
|
return resolveRepoID(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SecretHandler) resolveEnvID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
func (h *SecretHandler) resolveEnvID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||||
@@ -69,23 +68,13 @@ type TimelineEvent struct {
|
|||||||
//
|
//
|
||||||
// GET /api/v1/repos/:owner/:repo/timeline?limit=60
|
// GET /api/v1/repos/:owner/:repo/timeline?limit=60
|
||||||
func (h *TimelineHandler) GetTimeline(w http.ResponseWriter, r *http.Request) {
|
func (h *TimelineHandler) GetTimeline(w http.ResponseWriter, r *http.Request) {
|
||||||
owner := chi.URLParam(r, "owner")
|
|
||||||
repoName := chi.URLParam(r, "repo")
|
|
||||||
|
|
||||||
limit := 60
|
limit := 60
|
||||||
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
|
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
|
||||||
limit = l
|
limit = l
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resolve repo ──────────────────────────────────────────────────────────
|
repo, ok := resolveRepo(h.db, w, r)
|
||||||
var u models.User
|
if !ok {
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,19 +30,7 @@ type accessTokenResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AccessTokenHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
func (h *AccessTokenHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
return resolveRepo(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AccessTokenHandler) canManage(repo *models.Repository, callerID int64) bool {
|
func (h *AccessTokenHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -55,19 +55,7 @@ func toWebhookResp(wh models.Webhook) webhookResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebhookHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
func (h *WebhookHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
return resolveRepo(h.db, w, r)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebhookHandler) canManage(repo *models.Repository, callerID int64) bool {
|
func (h *WebhookHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||||
|
|||||||
@@ -22,19 +22,13 @@ type WorkflowHandler struct{ db *xorm.Engine }
|
|||||||
func NewWorkflowHandler(db *xorm.Engine) *WorkflowHandler { return &WorkflowHandler{db: db} }
|
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) {
|
func (h *WorkflowHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
repo, ok := resolveRepo(h.db, w, r)
|
||||||
repoName := chi.URLParam(r, "repo")
|
if !ok {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
var owner models.User
|
var owner models.User
|
||||||
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
|
h.db.ID(repo.OwnerID).Get(&owner)
|
||||||
jsonError(w, "repository not found", http.StatusNotFound)
|
return repo, &owner, true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WorkflowHandler) canManage(repo *models.Repository, callerID int64) bool {
|
func (h *WorkflowHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||||
|
|||||||
+69
-10
@@ -14,18 +14,27 @@ import (
|
|||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
"github.com/forgeo/forgebucket/internal/api/handlers"
|
"github.com/forgeo/forgebucket/internal/api/handlers"
|
||||||
"github.com/forgeo/forgebucket/internal/api/middleware"
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
"github.com/forgeo/forgebucket/internal/config"
|
"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/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 := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(chimiddleware.Logger)
|
r.Use(chimiddleware.Logger)
|
||||||
r.Use(chimiddleware.RealIP)
|
r.Use(chimiddleware.RealIP)
|
||||||
r.Use(chimiddleware.Recoverer)
|
r.Use(chimiddleware.Recoverer)
|
||||||
|
r.Use(observability.Middleware())
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{"http://localhost:5173", cfg.InstanceURL},
|
AllowedOrigins: []string{"http://localhost:5173", cfg.InstanceURL},
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
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)
|
webhookH := handlers.NewWebhookHandler(engine)
|
||||||
prSettingsH := handlers.NewPRSettingsHandler(engine)
|
prSettingsH := handlers.NewPRSettingsHandler(engine)
|
||||||
lfsH := handlers.NewLFSHandler(engine)
|
lfsH := handlers.NewLFSHandler(engine)
|
||||||
exploreH := handlers.NewExploreHandler(engine)
|
exploreH := handlers.NewExploreHandler(engine)
|
||||||
dashH := handlers.NewDashboardHandler(engine)
|
dashH := handlers.NewDashboardHandler(engine)
|
||||||
auditH := handlers.NewAuditHandler(engine)
|
auditH := handlers.NewAuditHandler(engine)
|
||||||
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
|
healthH := handlers.NewHealthHandler(engine, bus)
|
||||||
|
repoHealthH := handlers.NewRepoHealthHandler(engine)
|
||||||
|
artifactH := handlers.NewArtifactHandler(engine, artifactRoot, &keys)
|
||||||
runnerH := handlers.NewRunnerHandler(engine)
|
runnerH := handlers.NewRunnerHandler(engine)
|
||||||
|
gitopsH := handlers.NewGitOpsHandler(engine, bus)
|
||||||
|
fedH := handlers.NewFederationHandler(engine, cfg)
|
||||||
envH := handlers.NewEnvironmentHandler(engine, bus)
|
envH := handlers.NewEnvironmentHandler(engine, bus)
|
||||||
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
|
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
|
||||||
workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
|
workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
|
||||||
secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret)
|
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 ───────────────────────────────────────────────
|
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||||
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
// 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)
|
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) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
|
||||||
// ── Public ────────────────────────────────────────────────────────────
|
// ── Public ────────────────────────────────────────────────────────────
|
||||||
r.Get("/explore/repos", exploreH.Repos)
|
r.Get("/explore/repos", exploreH.Repos)
|
||||||
r.Get("/explore/users", exploreH.Users)
|
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.
|
// Generates a CSRF token + cookie. SPA calls this once on load.
|
||||||
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
|
||||||
token, err := middleware.NewCSRFToken(w, !cfg.Debug)
|
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("/me", userH.Me)
|
||||||
r.Get("/dashboard", dashH.Get)
|
r.Get("/dashboard", dashH.Get)
|
||||||
r.Get("/audit", auditH.List)
|
r.Get("/audit", auditH.List)
|
||||||
|
r.Get("/secrets/leaks", scanH.ListAllSecrets)
|
||||||
|
r.Get("/vulnerabilities", vulnH.ListAll)
|
||||||
r.Get("/pipelines/runs", pipeH.ListRecentRuns)
|
r.Get("/pipelines/runs", pipeH.ListRecentRuns)
|
||||||
|
|
||||||
// Workspace routes
|
// 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.Get("/artifacts", artifactH.List)
|
||||||
r.With(csrf).Post("/artifacts", artifactH.Upload)
|
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}/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.Route("/members", func(r chi.Router) {
|
||||||
r.Get("/", memberH.List)
|
r.Get("/", memberH.List)
|
||||||
r.With(csrf).Post("/", memberH.Add)
|
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.Get("/secrets", secretH.ListRepoSecrets)
|
||||||
r.With(csrf).Post("/secrets", secretH.UpsertRepoSecret)
|
r.With(csrf).Post("/secrets", secretH.UpsertRepoSecret)
|
||||||
r.With(csrf).Delete("/secrets/{name}", secretH.DeleteRepoSecret)
|
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.Get("/lfs-settings", lfsH.Get)
|
||||||
r.With(csrf).Put("/lfs-settings", lfsH.Update)
|
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.Route("/environments", func(r chi.Router) {
|
||||||
r.Get("/", envH.ListEnvironments)
|
r.Get("/", envH.ListEnvironments)
|
||||||
r.With(csrf).Post("/", envH.CreateEnvironment)
|
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.Get("/secrets", secretH.ListEnvSecrets)
|
||||||
r.With(csrf).Post("/secrets", secretH.UpsertEnvSecret)
|
r.With(csrf).Post("/secrets", secretH.UpsertEnvSecret)
|
||||||
r.With(csrf).Delete("/secrets/{name}", secretH.DeleteEnvSecret)
|
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)
|
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 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.
|
// In production the built React app is embedded and served from staticFiles.
|
||||||
if cfg.Debug {
|
if cfg.Debug {
|
||||||
|
|||||||
@@ -30,10 +30,20 @@ type Config struct {
|
|||||||
// Event bus
|
// Event bus
|
||||||
NATSUrl string
|
NATSUrl string
|
||||||
|
|
||||||
|
// GitOps
|
||||||
|
GitOpsReconcileInterval int // seconds between periodic drift checks; 0 disables
|
||||||
|
|
||||||
// Federation
|
// Federation
|
||||||
InstanceURL string
|
InstanceURL string
|
||||||
InstanceName 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
|
// Dev
|
||||||
Debug bool
|
Debug bool
|
||||||
}
|
}
|
||||||
@@ -46,7 +56,8 @@ func Load() (*Config, error) {
|
|||||||
ArtifactRoot: getEnv("ARTIFACT_ROOT", filepath.Join(filepath.Dir(repoRoot), "artifacts")),
|
ArtifactRoot: getEnv("ARTIFACT_ROOT", filepath.Join(filepath.Dir(repoRoot), "artifacts")),
|
||||||
Debug: getEnvBool("DEBUG", false),
|
Debug: getEnvBool("DEBUG", false),
|
||||||
|
|
||||||
NATSUrl: getEnv("NATS_URL", ""),
|
NATSUrl: getEnv("NATS_URL", ""),
|
||||||
|
GitOpsReconcileInterval: getEnvInt("GITOPS_RECONCILE_INTERVAL", 300),
|
||||||
InstanceURL: getEnv("INSTANCE_URL", ""),
|
InstanceURL: getEnv("INSTANCE_URL", ""),
|
||||||
InstanceName: getEnv("INSTANCE_NAME", "ForgeBucket"),
|
InstanceName: getEnv("INSTANCE_NAME", "ForgeBucket"),
|
||||||
}
|
}
|
||||||
@@ -57,6 +68,10 @@ func Load() (*Config, error) {
|
|||||||
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
|
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
|
||||||
cfg.CSRFSecret = requireEnv("CSRF_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
|
// Optional OIDC
|
||||||
cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER")
|
cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER")
|
||||||
cfg.OIDCClientID = os.Getenv("OIDC_CLIENT_ID")
|
cfg.OIDCClientID = os.Getenv("OIDC_CLIENT_ID")
|
||||||
@@ -91,6 +106,18 @@ func getEnv(key, fallback string) string {
|
|||||||
return fallback
|
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 {
|
func getEnvBool(key string, fallback bool) bool {
|
||||||
v := os.Getenv(key)
|
v := os.Getenv(key)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/domain/sbom"
|
||||||
"github.com/forgeo/forgebucket/internal/events"
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
"github.com/forgeo/forgebucket/internal/models"
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
@@ -17,12 +18,13 @@ import (
|
|||||||
// advances the DAG as jobs complete. It does NOT execute jobs directly —
|
// advances the DAG as jobs complete. It does NOT execute jobs directly —
|
||||||
// that is the RunnerManager's responsibility.
|
// that is the RunnerManager's responsibility.
|
||||||
type Orchestrator struct {
|
type Orchestrator struct {
|
||||||
db *xorm.Engine
|
db *xorm.Engine
|
||||||
bus events.EventBus
|
bus events.EventBus
|
||||||
|
sbomGen *sbom.Generator
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOrchestrator(db *xorm.Engine, bus events.EventBus) *Orchestrator {
|
func NewOrchestrator(db *xorm.Engine, bus events.EventBus, sbomGen *sbom.Generator) *Orchestrator {
|
||||||
return &Orchestrator{db: db, bus: bus}
|
return &Orchestrator{db: db, bus: bus, sbomGen: sbomGen}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start subscribes to relevant NATS subjects and blocks until ctx is cancelled.
|
// 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.
|
// Create job + step records for every job in the workflow.
|
||||||
for jobName, wfJob := range wf.Jobs {
|
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{
|
job := &models.PipelineJob{
|
||||||
RunID: run.ID,
|
RunID: run.ID,
|
||||||
Name: jobName,
|
Name: jobName,
|
||||||
@@ -231,6 +237,9 @@ func (o *Orchestrator) advanceDAG(runID, jobID int64, result string) {
|
|||||||
run.FinishedAt = &now
|
run.FinishedAt = &now
|
||||||
o.db.ID(run.ID).Cols("status", "finished_at").Update(&run) //nolint:errcheck
|
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
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -283,6 +283,23 @@ func RepoSize(repoPath string) int64 {
|
|||||||
return total
|
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.
|
// SetDefaultBranch updates HEAD to point at the given branch name.
|
||||||
func SetDefaultBranch(repoPath, branch string) error {
|
func SetDefaultBranch(repoPath, branch string) error {
|
||||||
_, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
|
_, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 == '/'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] + "..."
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] + "..."
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
type EventBus interface {
|
type EventBus interface {
|
||||||
Publish(subject string, payload any) error
|
Publish(subject string, payload any) error
|
||||||
Subscribe(subject string, handler func(subject string, data []byte)) (func(), error)
|
Subscribe(subject string, handler func(subject string, data []byte)) (func(), error)
|
||||||
|
Healthy() bool
|
||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ func (b *NATSBus) Subscribe(subject string, handler func(subject string, data []
|
|||||||
return func() { sub.Unsubscribe() }, nil //nolint:errcheck
|
return func() { sub.Unsubscribe() }, nil //nolint:errcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *NATSBus) Healthy() bool { return b.nc.IsConnected() }
|
||||||
|
|
||||||
func (b *NATSBus) Close() {
|
func (b *NATSBus) Close() {
|
||||||
if err := b.nc.Drain(); err != nil {
|
if err := b.nc.Drain(); err != nil {
|
||||||
log.Printf("nats: drain: %v", err)
|
log.Printf("nats: drain: %v", err)
|
||||||
@@ -75,6 +78,7 @@ type NoOpBus struct{}
|
|||||||
|
|
||||||
func (NoOpBus) Publish(_ string, _ any) error { return nil }
|
func (NoOpBus) Publish(_ string, _ any) error { return nil }
|
||||||
func (NoOpBus) Subscribe(_ string, _ func(string, []byte)) (func(), error) { return func() {}, nil }
|
func (NoOpBus) Subscribe(_ string, _ func(string, []byte)) (func(), error) { return func() {}, nil }
|
||||||
|
func (NoOpBus) Healthy() bool { return true }
|
||||||
func (NoOpBus) Close() {}
|
func (NoOpBus) Close() {}
|
||||||
|
|
||||||
// New returns a NATSBus if url is non-empty, otherwise a NoOpBus.
|
// New returns a NATSBus if url is non-empty, otherwise a NoOpBus.
|
||||||
|
|||||||
@@ -79,6 +79,29 @@ type LogChunkEvent struct {
|
|||||||
Content string `json:"content"`
|
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.
|
// WSEnvelope wraps any event for delivery over the WebSocket connection.
|
||||||
type WSEnvelope struct {
|
type WSEnvelope struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
|
|||||||
@@ -2,6 +2,28 @@ package models
|
|||||||
|
|
||||||
import "time"
|
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 {
|
type FederationActor struct {
|
||||||
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
UserID int64 `xorm:"'user_id' notnull unique index" json:"userId"`
|
UserID int64 `xorm:"'user_id' notnull unique index" json:"userId"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -46,5 +46,29 @@ func Run(engine *xorm.Engine) error {
|
|||||||
if err := Run011(engine); err != nil {
|
if err := Run011(engine); err != nil {
|
||||||
return err
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type PullRequest struct {
|
|||||||
SourceBranch string `xorm:"'source_branch' notnull varchar(255)" json:"sourceBranch"`
|
SourceBranch string `xorm:"'source_branch' notnull varchar(255)" json:"sourceBranch"`
|
||||||
TargetBranch string `xorm:"'target_branch' default 'main' varchar(255)" json:"targetBranch"`
|
TargetBranch string `xorm:"'target_branch' default 'main' varchar(255)" json:"targetBranch"`
|
||||||
Status PRStatus `xorm:"'status' default 'open' varchar(16)" json:"status"`
|
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"`
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
|
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Repository struct {
|
|||||||
IsPrivate bool `xorm:"'is_private' default false" json:"isPrivate"`
|
IsPrivate bool `xorm:"'is_private' default false" json:"isPrivate"`
|
||||||
DefaultBranch string `xorm:"'default_branch' default 'main' varchar(255)" json:"defaultBranch"`
|
DefaultBranch string `xorm:"'default_branch' default 'main' varchar(255)" json:"defaultBranch"`
|
||||||
DiskPath string `xorm:"'disk_path' notnull" json:"-"`
|
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"`
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
|
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIKGMjCu0NdczHQ7BRDeo0hTOLauF9vOenWl0HlyN4bzToAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAE+VL1HhQ1us0QfNH+5Var8lo5Oww83B+QDQ2obzHL4JZl0UM3kVAB
|
||||||
|
SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
|
||||||
|
-----END EC PRIVATE KEY-----
|
||||||
Reference in New Issue
Block a user