Compare commits

..
24 Commits
Author SHA1 Message Date
Tomas DvorakandDevin a05d225016 docs(roadmap): mark UI/UX audit + design previews shipped, fix stale multi-node note
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 10:57:33 +02:00
Tomas DvorakandDevin 61a33a066f chore: gitignore local /design preview folder
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 10:57:02 +02:00
Tomáš DvořákandDevin f88b4f1bf3 fix(ui): UI/UX pass — connectivity probe, mobile overflow, real metrics, deep links (#85)
Found during a browser-driven audit of the whole app:

- connectivity banner showed permanently: pingServer() resolved
  undefined, which React Query treats as an error; returns boolean now
- Vite dev proxy now forwards /health so the probe actually measures
  the backend, and the API target is env-overridable (VITE_API_PROXY)
- auth-session removed from the persisted query cache — stale sessions
  no longer rehydrate into a false signed-in flash
- workspace Observability tab no longer shows fabricated random metrics
  ("87K Active User", fake performance/speed-test cards); the dashboard
  now aggregates real per-service metrics (CPU, memory, network, instances)
- service detail tabs write ?section= so tab state is shareable/deep-linked
- Security page auto-selects the first project instead of rendering blank
- People page user-creation form no longer overflows its card
  (minmax(0,1fr) grid tracks; same fix on env-var rows)
- page padding is responsive (px-4 sm:px-8) so mobile keeps content width;
  Projects header wraps instead of clipping the "+ new project" button
- template deploy modal shows a loading state while the session resolves
  instead of flashing the sign-in prompt
- audit log table strips the CIDR suffix from displayed IPs
- docs list excerpts no longer repeat the document title
- activity filter placeholder "Search search…" → "Search events…"
- workspace view tabs get more room: ⌘K button now waits for lg:

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 10:56:33 +02:00
Tomáš Dvořák 0dc5857b5f refactor(db): port operations, settings, audit, and misc metadata to sqlc (#84)
* refactor(db): port operations, settings, audit, activity, and misc service metadata to sqlc

Covers the cross-cutting surface: operations board reads, app settings,
notification producers, activity/audit inserts, builds upsert/cancel,
git clone credential resolution, invites, environments, registry auth,
project/service variables, HA resolver, boot sweeps, and admin user ops.
Dynamic filtered lists and Better Auth table probes stay raw by design.

Authored By: TDvorak <[email protected]>

* refactor(db): port apwhy gateway handlers and proxy data plane to sqlc

Final tranche — every static call site now uses generated queries. Raw SQL
remains only where the statement is assembled at runtime (filtered lists,
sparse updates, admin count closure) or probes Better Auth's auth_users
table, which lives outside the local schema.

Authored By: TDvorak <[email protected]>
2026-10-10 09:27:30 +02:00
Tomáš Dvořák 66301bb68a refactor(db): port auth and security handlers to sqlc (#83)
- auth.sql: user lookups, counts, register/mirror inserts, profile update
- security.sql: access EXISTS, vulnerability list/metrics/status,
  framework list, latest scan/report, project-id guards
- NULL description/service_id rows stay filtered, matching the original
  scan-error-and-skip behavior
- auth_users probe and filtered audit-log queries stay raw: one is a
  Better Auth table outside the schema, the other is dynamically built
- TestSecurityLiveInvariants covers the access predicate, severity
  ordering, upsert conflict path, and latest-row lookups against live
  Postgres

Authored By: TDvorak <[email protected]>
2026-10-10 07:53:06 +02:00
Tomáš Dvořák 6bcbc1369e refactor(db): port git providers, repos, and webhook receiver to sqlc (#82)
- git.sql: provider/repo/webhook CRUD, push-dispatch service matcher
- ListServicesForPush folds the dynamic branch clause into a static
  predicate; COALESCE fallbacks match other service loaders
- git_receiver dispatch writes reuse deployment queries
- TestGitLiveInvariants: live coverage for upsert, search, webhook,
  and branch matching semantics

Authored By: TDvorak <[email protected]>
2026-10-10 05:31:21 +02:00
Tomáš DvořákandDevin 81a4ca28d8 refactor(db): port sleep/wake sweeper to sqlc (#81)
Tranche 7 — all 11 raw sites in sleeper.go. Adds sleep_enabled and
sleep_idle_minutes to schema.sql and three runtime.sql queries:
ListSleepCandidates, GetServiceDomainText, GetServiceStatusText. Status
writes reuse SetServiceStatus; the wake loader reuses
GetServiceRuntimeWithOwner (the projects join is FK-safe).

Also drops a dead port read in handleServiceSleep that was loaded but
never used.

TestRuntimeLiveInvariants now covers the sweep filter and both text
lookups on live Postgres.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 05:11:34 +02:00
Tomáš DvořákandDevin 269e031761 refactor(db): port cron jobs to sqlc (#80)
Tranche 6 — 15 of 17 raw sites in cron.go. Adds cron_jobs and
cron_executions to schema.sql and a full query set: job CRUD, owner
joins (via project and via service), execution list/insert/finish,
due-job scan for the scheduler, run bookkeeping, and retention trim.

The filtered list query and the sparse UPDATE builder stay raw — both
are genuinely dynamic. Go models keep string IDs; cronJobFrom and
cronExecutionFrom map uuid/null types at the boundary.

TestCronLiveInvariants covers insert/read, both owner joins, the
trigger read, execution lifecycle, retention trim, and delete on live
Postgres.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 04:27:16 +02:00
Tomáš DvořákandDevin ce71b5af24 refactor(db): port runtime ops to sqlc (#79)
Tranche 5 — all 24 raw sites in runtime.go. Adds runtime.sql with the
legacy-column COALESCE loader (type/image/command/git_repo fall back to
service_type/image_name/start_command/source_url), env-var reads,
sibling refs, deployed-image concat, and conditional published-port /
replica writes. Status transitions reuse SetServiceStatus.

loadReadableService now shares GetServiceRuntimeWithOwner — the projects
join is safe (FK) and drops a duplicated 24-column select.

TestRuntimeLiveInvariants verifies the COALESCE chain against a service
seeded with only legacy columns, plus refs, port, variables, agent
listing, image concat, and conditional updates on live Postgres.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 04:18:51 +02:00
Tomáš DvořákandDevin cca20f7eae refactor(db): port deployments to sqlc (#78)
Tranche 4: all 32 raw sites in deployments.go — paginated service list,
the accessible-feed joins (owner/member/admin/approved), service+owner
load, deployment insert, status transitions (fail/complete/set), the
10-field progress sync, cancel access check, rollback reads/inserts, and
the published_port reuse read. The shared serviceForDeployFromRow mapper
now feeds both the preview and deployments loaders. Live test covers the
full lifecycle: insert, list, access join, feed predicate, queued →
deployed sync, failed transition.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 04:01:40 +02:00
Tomáš DvořákandDevin 202fdd7b5a refactor(db): port preview environments to sqlc (#77)
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
Tranche 3: all 30 raw sites in preview_environments.go — list/detail
joins, access-check reads, create/update/delete, promote, user cleanup,
TTL sweep, and the status-sync UPDATE...FROM CASE. Deployments and
preview_environments schema.sql definitions completed to match runtime.
Shared mapper converts generated rows to the PreviewEnvironment DTO;
DeleteServiceByID from tranche 2 is reused for clone teardown.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:39:54 +02:00
Tomáš DvořákandDevin 687555fc78 refactor(db): port service clone/move ops to sqlc (#76)
Tranche 2: cloneServiceRow's INSERT...SELECT (volumes and traefik_labels
now read straight from the source row — traefik_labels was silently
dropped before, and the jsonb param round-trip goes away), domain and
variable copies, name-conflict counts, move UPDATE, project owner check.
Also fixes serviceWriteAccess: user_id is a context string, not a
uuid.UUID — the tranche-1 cast silently failed owner checks for
non-admins. Live tests cover the domain invariants and a full clone
round-trip (config, domains, variables).

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:25:07 +02:00
Tomáš DvořákandDevin 302c595380 refactor(db): port metrics storage + service domains to sqlc (#75)
First tranche of the ~300-site raw SQL port. Metrics: node/service/
instance rows via generated queries; aggregation moved off TimescaleDB's
time_bucket onto portable date_bin (PG14+); interval uptime returns epoch
seconds; per-container writes dropped (container_metrics FKs a different
model — collector shape never matched). Domains: all reads/writes through
sqlcdb including the create transaction via WithTx; default-domain and
services.domain invariants unchanged. Both paths covered by live
Postgres tests gated on METRICS_TEST_DSN.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:14:03 +02:00
Tomáš DvořákandDevin ca8eab4a65 feat(templates): visual graph builder (#74)
Canvas mode in the template editor (lazy-loaded React Flow): service
nodes for web/worker/cron/database, connect handles to set depends_on,
per-node inspector (image/repo, engine, port, health check, env,
volumes), live validation (dup keys, missing deps, cycles, missing
source), and an Apply step that serializes back to the version-2 graph
config JSON.

- builder-model.ts is pure: config↔canvas round-trip, layered
  auto-layout, flat-legacy→single-node migration, validation —
  16 vitest cases cover all of it
- node positions persist under config.canvas (backend ignores unknown
  config keys, so no schema change)
- flat legacy templates open as a single node so nothing is lost on
  edit

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:03:31 +02:00
Tomáš DvořákandDevin a6b8397dd8 test: cron schedule + webhook URL + canvas layout coverage (#73)
- calculateNextRun: field-count, garbage, bad-zone errors; CRON_TZ
  actually shifts the instant (UTC vs Asia/Tokyo)
- validWebhookURL: http/https only, host required, whitespace trimmed
- canvas layout math extracted to canvas/layout.ts (component files may
  only export components) and covered: rectsOverlap gap zone,
  resolveFreeSpot grid-snap/collision/bounds-fallback, groupSlot
  fit/append/grow/ignore-dragged, buildMetadataFromFlow serialization

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 02:34:51 +02:00
Tomáš DvořákandDevin c4307034ec feat(setup): first-run wizard — admin, GitHub App, tunnel, done (#71)
* feat(setup): first-run wizard — admin, GitHub App, tunnel, done

- GET /api/v1/setup/status (public): has_users, needs_setup,
  has_github_app, has_tunnel. app_settings.setup_completed flag; installs
  with existing projects auto-complete so upgrades never see the wizard.
- POST /api/v1/setup/complete records the flag.
- /setup page: progress rail + four steps — create admin (links into the
  bootstrap sign-up with redirect back), GitHub App manifest flow,
  optional Cloudflare tunnel token, finish. Every optional step is
  skippable; nothing blocks.
- PlatformShell redirects signed-in users to /setup while needs_setup.
- The GitHub App callback honors a sessionStorage return marker set by
  the wizard so provisioning resumes at /setup instead of /settings.
- OpenAPI documents both endpoints.

Verified live: status reports auto-completed on this install,
complete persists the flag, response shape matches the client.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(setup): wizard step-state coverage

Five cases across the state machine: fresh-install account prompt,
sign-in resume, git-provider step, tunnel step, and finish.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 02:17:41 +02:00
Tomáš DvořákandDevin 985d22798c fix(deploy): don't split digest-pinned image refs (#72)
splitImageReference checked for '@' AFTER the last colon — but digests
put '@' before it, so `nginx@sha256:abc` split into
name=nginx@sha256/tag=abc. The deploy sync loop wrote those mangled
pairs into the deployments table for any digest-pinned image.

Any ref containing '@' now returns whole with the fallback tag. Table
tests cover registry ports, digests, and tag+digest combos.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 02:17:36 +02:00
Tomáš DvořákandDevin 26f495ac8e feat(offline): persisted query cache + unreachable banner (web + mobile) (#70)
* feat(offline): persisted query cache + unreachable banner (web + mobile)

- Web: PersistQueryClientProvider on localStorage (24h maxAge); mobile:
  AsyncStorage persister (7d). Both exclude high-churn keys (logs,
  notifications, metrics) — stale tails are worse than none.
- networkMode 'offlineFirst' so cached data serves while the refetch
  attempt fails.
- Web: ConnectivityBanner in PlatformShell pings public /health every
  15s + listens to online/offline events — slim "last-known data" bar
  so persisted state can't masquerade as live.
- Mobile: same banner in the tabs layout — /health ping plus
  expo-network's local signal distinguishes "no connection" from
  "instance down".

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(mobile): cron jobs screen — schedules, run-now, execution history

- /service/cron lists the service's jobs with schedule, timezone,
  last/next run, and last status; "Run now" triggers via the existing
  POST /cron-jobs/:id/trigger and "History" expands recent executions
  with status + output/error.
- Manage row on the service detail + Stack registration.

Verified live: created */5 job on a running service, triggered it —
execution recorded with status success and captured stdout.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 01:55:34 +02:00
Tomáš DvořákandDevin 9243d020cc feat(notifications): ntfy/Gotify push channels with live delivery (#69)
Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.

- POST/GET/DELETE /notifications/channels + POST .../:id/test which
  pushes a real probe so misconfiguration surfaces before alerts depend
  on it (502 DELIVERY_FAILED on endpoint errors).
- ntfy: POST to endpoint (topic in path), Title/Tags headers, optional
  Bearer token. Gotify: POST {endpoint}/message?token=...
- Delivery is best-effort by design — a dead relay never breaks the
  producing flow; one shared 5s-timeout client.
- CLI `notifications channels list|add|test|remove`, MCP tools,
  OpenAPI paths + NotificationChannel schema.
- Web settings "Push notifications" section and a Push channels card on
  the mobile notifications tab (add/test/remove).

Verified live: channel CRUD, test-ping hit a recording endpoint with
correct headers/body, and a real deployment fanned out "Deployment
succeeded" through the channel end-to-end.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 01:39:09 +02:00
Tomáš DvořákandDevin 60145f7b21 feat(environments): env CRUD, workspace switcher, CLI + MCP parity (#68)
Environment lanes existed as seeded rows (production/preview/development)
with a per-service environment label, but there was no way to list,
create, or delete them. Adds the management surface and the switcher:

- GET /projects/:id/environments with live service counts (sqlc)
- POST /projects/:id/environments — lowercase dns-label names, 1-50
  chars, unique per project (409 on dupe)
- DELETE /environments/:id — refuses while it holds services (409)
- Workspace service list gains env filter chips with counts plus an
  inline "+ env" creator
- CLI: containr environments list|create|delete (--yes on delete);
  MCP: containr_environments_list/create/delete (delete needs confirm)
- OpenAPI: new Environments tag + ProjectEnvironment schema

Live-verified: list counts, name validation, dupe 409, empty delete,
non-empty 409, CLI list.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 01:17:32 +02:00
Tomáš DvořákandDevin dd7b735b38 feat(preview): real preview environments — clone, deploy, TTL, promote (#67)
Preview environments were bookkeeping only: a fabricated deployment row
marked "running" and a fake *.preview.containr.local URL. Now a preview
is a real service clone — full config copy (source, env, volumes, access),
git_branch pinned to the requested branch, its own preview hostname under
the clone's node default domain, and a real deployment through the queue.

- cloneServiceRow extracted from handleCloneService for reuse
- preview_environments.preview_service_id links to the clone
- minute sweeper: expires past-TTL previews (removes clone runtime + row)
  and syncs preview status from the clone's live service status
- delete/cleanup paths tear the clone down, not just the row
- promote redeploys the SOURCE service at the preview branch and retires
  the clone — a real deployment, not a pending stub

Verified live: create → clone → deploy → running container; TTL expiry →
clone + container gone; promote → real deploy on the source, preview
stopped. Unit test covers the DNS-safe clone naming.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:54:07 +02:00
Tomáš DvořákandDevin d5909172ae feat(gateway): /g/:slug proxy data plane with key auth and rate limits (#66)
The admin surface under /admin/gateway managed api_services and api_keys,
but no public route ever consumed them. Mounts /g/:slug and /g/:slug/* on
the root router (outside /api/v1 so route_prefix stays clean) and runs the
full pipeline: slug lookup -> X-API-Key/Bearer extraction -> prefix-indexed
bcrypt key auth -> allowed_service_ids scope -> per-minute buckets (key and
service caps) -> monthly usage_counters quotas -> gateway.ProxyRequest with
per-service timeout and upstream auth headers -> usage/metrics/incident
recording.

Live-verified: proxied GET/POST, 401/404/429 boundaries, counter upserts,
UPSTREAM_5XX incidents. Unit tests cover URL building, the rpm bucket, key
extraction, and the service allowlist.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:29:57 +02:00
Tomáš DvořákandDevin 1409186a1f feat(nodes): per-node default domain with auto service hostnames (#65)
node_agents.default_domain gives each agent a base domain. Services
placed on it (explicit pin or spread) with no explicit service_domains
get <name>.<default_domain> — resolved in serviceDomainNames so edge
routing, wake placeholders, and public_url all see the same names.
Spread services union every eligible node's base domain; explicit
domains always win.

Surfaces: PUT /agents/:id {default_domain} (hostname-validated), CLI
nodes update --domain, MCP arg docs, OpenAPI + regenerated types,
node-detail inline editor.

Also marks the already-shipped backup-import roadmap item.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:07:30 +02:00
Tomáš DvořákandDevin a4bb7ad559 feat(services): traefik routing overrides via allowlisted middleware labels (#64)
services.traefik_labels (jsonb) holds operator-defined Traefik
middlewares keyed 'middlewares.<name>.<type>[.<field>]', validated
against an allowlist at write time. edgeLabels (extracted from
createReplica) scopes each definition to the service router as
<router>-<name> and auto-attaches it to the router's middleware chain
after builtins. Router/service/provider-level keys are rejected.

Surfaces: create/update API fields, services create/update
--traefik-label (repeatable, key=value), MCP arg docs, OpenAPI +
regenerated types, service-page Access editor (Routing overrides
card + textarea).

Also marks three stale ROADMAP2 items shipped: notification producers
round-out, build-on-node (PR #60), mobile env/domains/scaling (PR #61).

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 23:47:25 +02:00
147 changed files with 17968 additions and 3145 deletions

No files matched your search

+3
View File
@@ -124,3 +124,6 @@ ref/
.playwright-mcp/
canvas-*.png
.vercel
# Local design previews (not shipped)
design/
+86 -38
View File
@@ -1,6 +1,6 @@
# Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase
Last verified: **2026-10-10** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`).
@@ -192,8 +192,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
compares vs edge IP → `ok|wrong-target|pending`.
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed.
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation (middlewares, headers, redirects).
- [x] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation. Keys `middlewares.<name>.<type>[.<field>]` scope
to the service router (`svc-<id>-<name>`), auto-attach after builtins;
router/service/provider-level keys rejected. API + CLI
(`--traefik-label`) + MCP + service page Access editor.
- [x] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
@@ -223,7 +226,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
{target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Deferred until Phase F lands real second
nodes — nothing to migrate between today.
- [ ] **Backup import** — upload archive → new restore point (pairs with
- [x] **Backup import** — `POST /databases/:id/backups/import` accepts a
`.tar.gz` (multipart `file` or raw body, 2 GiB cap, gzip-magic
validated), stores it in the backups volume via a holder container,
registers a completed restore point, and ships offsite when a backup
target is configured. `api/backuptargets.go`. (pairs with
migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
@@ -245,9 +252,12 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
- [x] **Graph deploy pipeline** — creates services in dependency order,
resolves vars/secrets, triggers builds. `deployTemplateGraph`,
`POST /templates/deploy` (inline), `POST /projects/:id/import-compose`.
- [ ] **Visual template builder** — canvas mode: drop git/image/db
nodes, wire refs, set order, save/publish. Reuses existing canvas
primitives (dflow's `templates/compose` is exactly this).
- [x] **Visual template builder** — canvas mode in the template editor:
web/worker/cron/database nodes, handles wire `depends_on`, inspector
edits image/repo/engine/port/env/volumes, live validation, applies
back to the version-2 graph JSON. Positions persist under
`config.canvas`; flat legacy configs open as a single node.
`builder-model.ts` + `TemplateBuilder.tsx`.
- [x] **Catalog growth** — seeded graph stacks for n8n, Gitea,
Nextcloud, Vaultwarden, Umami, Immich (Plausible already shipped).
`TestSeedTemplatesResolveCleanly` keeps every seed parseable,
@@ -327,8 +337,9 @@ and wired refs, one click or one CLI call.
and local + tags are rejected, empty array clears. Surfaces:
`--placement-tags` on services create/update, `--tags` on
`nodes update`, MCP args, OpenAPI, node-detail tag editor +
service-detail placement-tags input. Remaining: build-on-node,
remote sleep/wake, spread weighting by real capacity.
service-detail placement-tags input. Build-on-node and remote
sleep/wake both shipped (see below). Remaining: spread weighting
by real capacity.
- [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a
`self_upgrade` command carrying platform + sha256 + server version;
the agent downloads its replacement from `GET /agents/download/
@@ -341,19 +352,22 @@ and wired refs, one click or one CLI call.
CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page
button (online-gated). Ceilings: linux amd64/arm64 only; agents on
other platforms get a `NO_BINARY` 409.
- [🟡] **Build-on-node** — private registry pulls shipped: the dispatch
payload carries the project owner's `registries` credentials for the
image host; the agent `docker login`s for the pull and logs out after,
and command payloads are scrubbed of passwords on completion. Agent
also verifies the container is still running ~1.5s after `run` so
crash-loops fail the deploy instead of reporting healthy. Remaining:
real builds on the node (source checkout + build daemon) — pulls
already work.
- [x] **Build-on-node** — private registry pulls + real builds shipped.
Git sources materialize via `internal/source` checkout (branch/commit,
`GIT_ASKPASS` credential injection, dumb-transport fallback), package
through `BuildManager.PackageContext`, ship as agent-token artifacts,
and `docker build` on the node (`build_image` command). Remote rollbacks
`docker save` → artifact → `load_image` on the node. Verified end-to-end
on a live agent (PR #60).
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
auto-domains for services on that node.
- [x] **Global/wildcard domain per node** (`node_agents.default_domain`) —
`PUT /agents/:id {default_domain}`; domainless services placed on the
node get `<name>.<default_domain>` (pinned → node's base; spread →
union of eligible nodes). `serviceDomainNames` resolves them so
routing, wake placeholders, and `public_url` all pick them up.
CLI `nodes update --domain`, MCP arg, node-detail editor.
## 8. Phase G — Platform polish
@@ -426,8 +440,11 @@ and wired refs, one click or one CLI call.
filters. Applied: audit logs (server-backed), builds (server + client
date-range), activity (severity server + client category/search).
Notifications table has no list surface yet — apply when one lands.
- [ ] **Notification producers round-out** — backups, agent offline,
upgrade available, security findings.
- [x] **Notification producers round-out** — all shipped: deployments
(deployments.go), backups (databases.go `createBackupProcess` → owner),
agent offline/online sweep (`reconcileAgentHealth` → admins),
upgrade-available (`checkForUpgrade`, tag-persisted) and security-scan
findings (`scanner.OnScanComplete` → project owner).
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
polls container NetworkIO deltas; idle past threshold → workload
@@ -492,28 +509,59 @@ and wired refs, one click or one CLI call.
a prior deployment, database start/stop/restart/backup-now.
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
deployment history, env-doctor issues surfaced on the service view.
- [ ] **env var edit + apply-redeploy, domain management, replica
scaling, cron execution history** — next iteration.
- [ ] **Push notifications** — zero-infra path = self-hosted
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
- [ ] **Offline resilience** — cached last-known state + unreachable
banner.
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
the instance is unreachable (cached last-known state, clear banner).
- [x] **env var edit + domain management + replica scaling** (PR #61 —
mobile v1.5: variables editor, domains screen, scale + sleep
settings, notifications tab, cron jobs screen with run-now and
execution history).
- [x] **Push notifications** — per-user `notification_channels`
(ntfy + Gotify): every `insertUserNotification` fans out best-effort
to enabled channels; CRUD + live test-ping via web settings, CLI
`notifications channels`, and MCP. FCM/APNs remain optional.
- [x] **Offline resilience** — AsyncStorage-persisted query cache
(7d, high-churn keys excluded) + banner when the instance is
unreachable (`/health` ping + expo-network).
- [x] **Status/offline resilience** — web persists the query cache to
localStorage (24h) and PlatformShell shows a "last-known data" banner
on offline/unreachable; CLI exits `ExitUnavailable` with a clean
"cannot reach" message (no stack traces).
## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
(bookkeeping only now). Pairs with clone + multi-domain.
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
- [x] **Gateway traffic path** — `/g/:slug/*` mounted → api_key auth →
rpm + monthly quota → `gateway/proxy.go` → usage_counters /
metrics_timeseries / incident_events.
- [x] **Preview environments runtime** — previews are real service clones:
branch override, queued deployment, node-domain hostnames, TTL sweeper,
promote = redeploy of the source at the preview branch.
- [x] **Environments** — CRUD (`GET/POST /projects/:id/environments`,
`DELETE /environments/:id`), switcher chips with service counts in the
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
service's own scope (one env per service); promote ships via preview
promote.
- [x] **sqlc port** — all static `database/sql` call sites → `sqlc/queries/` + generated `sqlcdb`. Merged: metrics + domains (#75), service clone/move (#76), preview environments (#77), deployments + rollback (#78), runtime ops (#79), cron (#80), sleep/wake sweeper (#81), git providers/repos/webhooks (#82), auth + security (#83), operations/settings/audit/activity/builds/misc + apwhy gateway (#84). Raw SQL remains only for runtime-assembled statements (filtered lists, sparse updates) and Better Auth `auth_users` probes.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path.
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
Phase G makes this self-serve) → Tunnel → done.
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
- [x] **UI/UX audit** — browser-driven pass over the whole app (PR #85):
fixed the stuck connectivity banner (`pingServer` returned
`undefined`), stale persisted sessions, fabricated observability
metrics (now real `getServiceMetrics` aggregation), mobile overflow,
Security blank state, deep-linkable service tabs, and a dozen
polish items.
- [x] **Design previews** — gitignored `/design` holds three on-brand
HTML concepts (console, service detail, landing) on the product's
token system with a live white-label accent switcher.
- [x] **First-run wizard** — public `GET /setup/status` + authed
`POST /setup/complete`; `/setup` page steps admin account → GitHub App
manifest flow (callback returns to the wizard via a session marker) →
optional Cloudflare tunnel token → done. Shell redirects signed-in
users to `/setup` while `needs_setup`; installs with existing projects
auto-complete so nobody gets nagged retroactively.
- [x] **Self-upgrade loop verification** — live-verified: admin
`POST /agents/:id/upgrade` → `self_upgrade` command → agent downloads
`/api/agents/download/linux-amd64` → sha256 verify → `--version`
sanity → atomic rename → result posts → `syscall.Exec` re-exec. Agent
ran `v0.0.1-old`, came back `v9.9.9-test`, command row `completed`.
- [ ] **Docs freshness, v1.0.0 release.**
## 11. Deliberately not imported from dflow
+21 -30
View File
@@ -12,6 +12,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -180,13 +181,13 @@ func handleGetProjectActivity(c *gin.Context) {
}
// Access check: approved / owner / member / admin.
var visible bool
if err := db.QueryRow(
`SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid)
FROM projects p WHERE p.id = $1`,
projectID, userID, contextIsAdmin(c),
).Scan(&visible); err != nil || !visible {
userUUID, _ := uuid.Parse(userID)
visible, err := sqlcdb.New(db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
ID: uuid.MustParse(projectID),
OwnerID: userUUID,
Column3: contextIsAdmin(c),
})
if err != nil || !visible {
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
return
}
@@ -237,12 +238,12 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
}
prune := func() {
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days)
n, err := sqlcdb.New(db.DB).PruneAuditLogs(context.Background(), int32(days))
if err != nil {
log.Printf("audit retention prune failed: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
if n > 0 {
log.Printf("audit retention: pruned %d rows older than %d days", n, days)
}
}
@@ -265,34 +266,24 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
// backfillAuditMetadata classifies rows written before severity/category/label
// existed so filters and the feed see uniform metadata.
func backfillAuditMetadata(db *database.DB) {
cursor := uuid.Nil.String()
q := sqlcdb.New(db.DB)
cursor := uuid.Nil
for {
rows, err := db.Query(
`SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
rows, err := q.ListAuditBackfillBatch(context.Background(), cursor)
if err != nil {
log.Printf("audit backfill scan failed: %v", err)
return
}
type row struct{ id, resource, action string }
var batch []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.resource, &r.action) == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
if len(rows) == 0 {
return
}
for _, r := range batch {
cursor = r.id
sev, cat, label := classifyAuditEvent(r.resource, r.action)
if _, err := db.Exec(
`UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`,
sev, cat, label, r.id,
); err != nil {
for _, r := range rows {
id, _ := uuid.Parse(r.ID)
cursor = id
sev, cat, label := classifyAuditEvent(r.Resource, r.Action)
if err := q.SetAuditLogMetadata(context.Background(), sqlcdb.SetAuditLogMetadataParams{
Severity: sev, Category: cat, Label: label, Column4: id,
}); err != nil {
log.Printf("audit backfill update failed: %v", err)
}
}
+23 -34
View File
@@ -52,15 +52,11 @@ func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config
return
}
var userID string
err = db.QueryRowContext(ctx, `
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id
`, email, string(hashed), name).Scan(&userID)
if err != nil {
if _, err := sqlcdb.New(db.DB).UpsertAdminUser(ctx, sqlcdb.UpsertAdminUserParams{
Email: email,
PasswordHash: string(hashed),
Name: name,
}); err != nil {
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
return
}
@@ -185,24 +181,18 @@ func handleAdminOverview(c *gin.Context) {
func handleAdminListUsers(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(c.Request.Context(), `
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
FROM users
ORDER BY created_at ASC
`)
rows, err := sqlcdb.New(db.DB).ListAdminUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return
}
defer rows.Close()
users := make([]adminUserRow, 0)
for rows.Next() {
var u adminUserRow
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
continue
}
users = append(users, u)
users := make([]adminUserRow, 0, len(rows))
for _, r := range rows {
users = append(users, adminUserRow{
ID: r.ID.String(), Email: r.Email, Name: r.Name,
AvatarURL: r.AvatarUrl, IsAdmin: r.IsAdmin, CreatedAt: r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
@@ -229,15 +219,16 @@ func handleAdminSetUserAdmin(c *gin.Context) {
return
}
result, err := db.ExecContext(c.Request.Context(),
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
req.IsAdmin, targetID,
)
targetUUID, _ := uuid.Parse(targetID)
n, err := sqlcdb.New(db.DB).SetUserAdmin(c.Request.Context(), sqlcdb.SetUserAdminParams{
IsAdmin: req.IsAdmin,
ID: targetUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
if n, _ := result.RowsAffected(); n == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
@@ -258,15 +249,13 @@ func handleAdminImpersonateUser(c *gin.Context) {
return
}
var email, name string
var isAdmin bool
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
userUUID, _ := uuid.Parse(targetID)
info, err := sqlcdb.New(db.DB).GetAdminUserInfo(c.Request.Context(), userUUID)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
email, name, isAdmin := info.Email, info.Name, info.IsAdmin
if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return
@@ -286,9 +275,9 @@ func handleAdminImpersonateUser(c *gin.Context) {
}
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
"actor_id": actorID,
"actor_id": actorID,
"target_email": email,
"expires_at": expiresAt.Format(time.RFC3339),
"expires_at": expiresAt.Format(time.RFC3339),
})
c.JSON(http.StatusOK, gin.H{
"token": token,
+40 -24
View File
@@ -30,21 +30,24 @@ import (
// NodeAgent represents a container orchestration agent
type NodeAgent struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
Port int `json:"port"`
Status string `json:"status"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"`
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
Port int `json:"port"`
Status string `json:"status"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"`
// Tags are operator-set placement labels; services with placement_tags
// only schedule onto agents carrying every required tag.
Tags []string `json:"tags"`
Tags []string `json:"tags"`
// DefaultDomain is this node's base domain — services placed here with
// no explicit domains get <name>.<default_domain> automatically.
DefaultDomain string `json:"default_domain"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"`
@@ -272,17 +275,18 @@ func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{
ID: row.ID,
Name: row.Name,
Hostname: row.Hostname,
IPAddress: row.IpAddress,
Port: int(row.Port),
Status: row.Status.String,
Version: row.Version.String,
AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable,
Tags: []string{},
Metadata: map[string]interface{}{},
ID: row.ID,
Name: row.Name,
Hostname: row.Hostname,
IPAddress: row.IpAddress,
Port: int(row.Port),
Status: row.Status.String,
Version: row.Version.String,
AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable,
Tags: []string{},
DefaultDomain: row.DefaultDomain,
Metadata: map[string]interface{}{},
}
if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time
@@ -572,6 +576,18 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
// Tags persist through their own column write — UpdateAgent keeps the
// register-path signature untouched.
resp := agentFromRow(updated)
if raw, ok := updates["default_domain"]; ok {
domain := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", raw)))
if domain != "" && !validHostname(domain) {
c.JSON(http.StatusBadRequest, gin.H{"error": "default_domain must be a valid hostname", "code": "VALIDATION"})
return
}
if err := h.q.SetAgentDefaultDomain(ctx, sqlcdb.SetAgentDefaultDomainParams{ID: id, DefaultDomain: domain}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update default domain"})
return
}
resp.DefaultDomain = domain
}
if raw, ok := updates["tags"]; ok {
var tags []string
if b, mErr := json.Marshal(raw); mErr == nil {
+121 -189
View File
@@ -2,6 +2,7 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"crypto/rand"
"database/sql"
@@ -14,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
@@ -160,21 +162,22 @@ func handleAPwhyServiceValidate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
var upstreamURL, healthPath string
var authHeader, authValue sql.NullString
var timeoutMs sql.NullInt64
err := db.QueryRowContext(ctx, `
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
if err != nil {
svcUUID, parseErr := uuid.Parse(serviceID)
var svc sqlcdb.GetAPServiceForValidateRow
var err error
if parseErr == nil {
svc, err = sqlcdb.New(db.DB).GetAPServiceForValidate(ctx, svcUUID)
}
if parseErr != nil || err != nil {
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
return
}
upstreamURL, healthPath := svc.UpstreamUrl, svc.HealthPath
authHeader, authValue := svc.UpstreamAuthHeader, svc.UpstreamAuthValue
timeout := 8000
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
timeout = int(timeoutMs.Int64)
if svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 {
timeout = int(svc.RequestTimeoutMs.Int32)
}
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
@@ -203,17 +206,19 @@ func handleAPwhyServiceValidate(c *gin.Context) {
validationStatus = "healthy"
}
_, _ = db.ExecContext(ctx, `
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`, validationStatus, message, serviceID)
q := sqlcdb.New(db.DB)
_ = q.SetAPServiceValidation(ctx, sqlcdb.SetAPServiceValidationParams{
LastValidationStatus: sql.NullString{String: validationStatus, Valid: true},
LastValidationMessage: sql.NullString{String: message, Valid: true},
ID: svcUUID,
})
if !ok {
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`, serviceID, message, status)
_ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true},
Message: message,
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
})
}
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
@@ -231,36 +236,24 @@ func handleAPwhyServicesList(c *gin.Context) {
ctx := context.Background()
// Query services from database
rows, err := db.QueryContext(ctx, `
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
FROM api_services
ORDER BY created_at DESC
`)
rows, err := sqlcdb.New(db.DB).ListAPServices(ctx)
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to query services", err.Error())
return
}
defer rows.Close()
services := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
var enabled int
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
if err != nil {
continue // Skip malformed rows
}
services := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
services = append(services, map[string]interface{}{
"id": id,
"name": name,
"slug": slug,
"upstreamUrl": upstreamURL,
"routePrefix": routePrefix,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
"id": r.ID.String(),
"name": r.Name,
"slug": r.Slug,
"upstreamUrl": r.UpstreamUrl,
"routePrefix": r.RoutePrefix,
"enabled": r.Enabled != 0,
"createdAt": r.CreatedAt,
"updatedAt": r.UpdatedAt,
})
}
@@ -299,14 +292,14 @@ func handleAPwhyServicesCreate(c *gin.Context) {
}
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err := db.QueryRowContext(ctx, `
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
svcID, err := sqlcdb.New(db.DB).InsertAPService(ctx, sqlcdb.InsertAPServiceParams{
Name: req.Name,
Slug: slug,
UpstreamUrl: req.UpstreamURL,
RoutePrefix: req.RoutePrefix,
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
})
if err != nil {
if isUniqueViolation(err) {
@@ -320,7 +313,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
}
serviceData := map[string]interface{}{
"id": id,
"id": svcID.String(),
"name": req.Name,
"slug": slug,
"upstreamUrl": req.UpstreamURL,
@@ -356,13 +349,15 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
enabled := int32(0)
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
enabled, serviceID)
svcUUID, _ := uuid.Parse(serviceID)
err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{
Enabled: enabled,
ID: svcUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -383,11 +378,7 @@ func handleAPwhyServicesPatch(c *gin.Context) {
func handleAPwhyKeysList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(context.Background(), `
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
FROM api_keys
ORDER BY created_at DESC
`)
rows, err := sqlcdb.New(db.DB).ListAPKeys(context.Background())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"ok": true,
@@ -395,34 +386,24 @@ func handleAPwhyKeysList(c *gin.Context) {
})
return
}
defer rows.Close()
keys := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled int
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
if err != nil {
continue
}
keys := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
key := map[string]interface{}{
"id": id,
"name": name,
"keyPrefix": keyPrefix,
"plan": plan,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
"id": r.ID.String(),
"name": r.Name,
"keyPrefix": r.KeyPrefix,
"plan": r.Plan,
"enabled": r.Enabled != 0,
"createdAt": r.CreatedAt,
"updatedAt": r.UpdatedAt,
}
if rpmLimit.Valid {
key["rpmLimit"] = rpmLimit.Int64
if r.RpmLimit.Valid {
key["rpmLimit"] = int64(r.RpmLimit.Int32)
}
if monthlyQuota.Valid {
key["monthlyQuota"] = monthlyQuota.Int64
if r.MonthlyQuota.Valid {
key["monthlyQuota"] = int64(r.MonthlyQuota.Int32)
}
keys = append(keys, key)
@@ -481,14 +462,14 @@ func handleAPwhyKeysCreate(c *gin.Context) {
}
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err = db.QueryRowContext(context.Background(), `
INSERT INTO api_keys (
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
keyID, err := sqlcdb.New(db.DB).InsertAPKey(context.Background(), sqlcdb.InsertAPKeyParams{
Name: input.Name,
KeyHash: keyHash,
KeyPrefix: keyPrefix,
Plan: plan,
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -501,7 +482,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{
"ok": true,
"data": gin.H{
"id": id,
"id": keyID.String(),
"name": input.Name,
"plan": plan,
"key": apiKey, // Only return the actual key once
@@ -532,13 +513,15 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
enabled := int32(0)
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
enabled, keyID)
keyUUID, _ := uuid.Parse(keyID)
err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{
Enabled: enabled,
ID: keyUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -560,23 +543,27 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
// Get counts from database
var totalServices, totalKeys, totalUsers int
q := sqlcdb.New(db.DB)
ctx := context.Background()
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil {
totalServices, err := q.CountAPServices(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to count services: " + err.Error(),
})
return
}
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil {
totalKeys, err := q.CountAPKeys(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to count api keys: " + err.Error(),
})
return
}
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil {
totalUsers, err := q.CountUsers(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to count users: " + err.Error(),
@@ -584,8 +571,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var totalRequests int
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil {
totalRequests, err := q.SumUsageCounters(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate request counters: " + err.Error(),
@@ -593,12 +580,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var requestsToday int
if err := db.QueryRowContext(context.Background(), `
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW())
`).Scan(&requestsToday); err != nil {
requestsToday, err := q.SumRequestMetricSince(ctx, "day")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate today's requests: " + err.Error(),
@@ -606,12 +589,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var requestsThisMonth int
if err := db.QueryRowContext(context.Background(), `
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW())
`).Scan(&requestsThisMonth); err != nil {
requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate monthly requests: " + err.Error(),
@@ -636,94 +615,47 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
func handleAPwhyAnalyticsTraffic(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
q := sqlcdb.New(db.DB)
ctx := context.Background()
topServices := make([]map[string]interface{}, 0)
serviceRows, err := db.QueryContext(context.Background(), `
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
FROM api_services s
LEFT JOIN usage_counters u ON u.service_id = s.id
GROUP BY s.id, s.name
ORDER BY total_requests DESC, s.name ASC
LIMIT 10
`)
if err == nil {
defer serviceRows.Close()
for serviceRows.Next() {
var serviceID, serviceName string
var totalRequests int
if scanErr := serviceRows.Scan(&serviceID, &serviceName, &totalRequests); scanErr == nil {
topServices = append(topServices, map[string]interface{}{
"service_id": serviceID,
"name": serviceName,
"requests": totalRequests,
})
}
if serviceRows, err := q.ListTopAPServices(ctx); err == nil {
for _, r := range serviceRows {
topServices = append(topServices, map[string]interface{}{
"service_id": r.ID.String(),
"name": r.Name,
"requests": r.TotalRequests,
})
}
}
requestsByDay := make([]map[string]interface{}, 0)
trafficRows, err := db.QueryContext(context.Background(), `
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', occurred_at)
ORDER BY DATE_TRUNC('day', occurred_at) ASC
`)
if err == nil {
defer trafficRows.Close()
for trafficRows.Next() {
var day string
var count int
if scanErr := trafficRows.Scan(&day, &count); scanErr == nil {
requestsByDay = append(requestsByDay, map[string]interface{}{
"day": day,
"requests": count,
})
}
if trafficRows, err := q.ListRequestsByDay(ctx); err == nil {
for _, r := range trafficRows {
requestsByDay = append(requestsByDay, map[string]interface{}{
"day": r.DayBucket,
"requests": r.Total,
})
}
}
statusCodes := make([]map[string]interface{}, 0)
statusRows, err := db.QueryContext(context.Background(), `
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
FROM incident_events
WHERE occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE(http_status, 0)
ORDER BY total DESC, status_code ASC
`)
if err == nil {
defer statusRows.Close()
for statusRows.Next() {
var code int
var total int
if scanErr := statusRows.Scan(&code, &total); scanErr == nil {
statusCodes = append(statusCodes, map[string]interface{}{
"status_code": code,
"count": total,
})
}
if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil {
for _, r := range statusRows {
statusCodes = append(statusCodes, map[string]interface{}{
"status_code": r.StatusCode,
"count": r.Total,
})
}
}
clientEvents := make([]map[string]interface{}, 0)
eventRows, err := db.QueryContext(context.Background(), `
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
ORDER BY total DESC, path ASC
LIMIT 20
`)
if err == nil {
defer eventRows.Close()
for eventRows.Next() {
var path string
var total int
if scanErr := eventRows.Scan(&path, &total); scanErr == nil {
clientEvents = append(clientEvents, map[string]interface{}{
"path": path,
"count": total,
})
}
if eventRows, err := q.ListClientEventPaths(ctx); err == nil {
for _, r := range eventRows {
clientEvents = append(clientEvents, map[string]interface{}{
"path": r.Path,
"count": r.Total,
})
}
}
+54 -34
View File
@@ -2,6 +2,9 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
@@ -11,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
type AuditLog struct {
@@ -98,16 +102,20 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
}
detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
)
err := sqlcdb.New(db.DB).InsertAuditLog(context.Background(), sqlcdb.InsertAuditLogParams{
ID: uuid.New(),
UserID: nullUUIDOrNil(userID),
Resource: resource,
ResourceID: nullUUIDOrNil(resourceID),
Action: action,
Details: pqtype.NullRawMessage{RawMessage: detailsJSON, Valid: true},
Severity: severity,
Category: category,
Label: label,
CreatedAt: time.Now().UTC(),
})
if err != nil {
}
@@ -131,16 +139,24 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
if uid, ok := userID.(string); ok {
userIDStr = uid
}
userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
)
var inet pqtype.Inet
_ = inet.Scan(c.ClientIP())
err := sqlcdb.New(db.DB).InsertAuditLogWithRequest(context.Background(), sqlcdb.InsertAuditLogWithRequestParams{
ID: uuid.New(),
UserID: nullUUIDOrNil(userIDStr),
Resource: resource,
ResourceID: nullUUIDOrNil(resourceID),
Action: action,
Column6: json.RawMessage(detailsJSON),
Column7: inet,
UserAgent: sql.NullString{String: c.GetHeader("User-Agent"), Valid: true},
Severity: severity,
Category: category,
Label: label,
CreatedAt: time.Now().UTC(),
})
if err != nil {
}
@@ -255,30 +271,29 @@ func handleGetResourceAuditLogs(c *gin.Context) {
resource := c.Param("resource")
resourceID := c.Param("id")
rows, err := db.Query(
`SELECT id, COALESCE(user_id::text, ''), resource, COALESCE(resource_id::text, ''), action, COALESCE(details::text, '{}'),
COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), created_at
FROM audit_logs
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100`,
userID, resource, resourceID,
)
rows, err := sqlcdb.New(db.DB).ListResourceAuditLogs(context.Background(), sqlcdb.ListResourceAuditLogsParams{
UserID: nullUUIDOrNil(userID),
Resource: resource,
ResourceID: nullUUIDOrNil(resourceID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
for _, row := range rows {
logs = append(logs, AuditLog{
ID: row.ID.String(),
UserID: row.UserID,
Resource: row.Resource,
ResourceID: row.ResourceID,
Action: row.Action,
Details: row.Details,
IPAddress: row.IpAddress,
UserAgent: row.UserAgent,
CreatedAt: row.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
@@ -302,3 +317,8 @@ func parseUUIDOrNil(raw string) interface{} {
}
return trimmed
}
func nullUUIDOrNil(raw string) uuid.NullUUID {
parsed, err := uuid.Parse(strings.TrimSpace(raw))
return uuid.NullUUID{UUID: parsed, Valid: err == nil}
}
+78 -46
View File
@@ -3,6 +3,8 @@ package api
import (
"bytes"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"errors"
@@ -63,11 +65,16 @@ func handleLogin(c *gin.Context) {
// Find user by email
var user User
var hashedPassword string
err := db.QueryRow(`
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
FROM users
WHERE email = $1
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := sqlcdb.New(db.DB).GetUserByEmailForAuth(context.Background(), req.Email)
user = User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
hashedPassword = urow.PasswordHash
if err == sql.ErrNoRows {
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
@@ -257,25 +264,30 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser
}
// A mirrored first account owns the platform, same as handleRegister.
var total int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil {
q := sqlcdb.New(db.DB)
total64, err := q.CountUsers(context.Background())
if err != nil {
return User{}, err
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := q.UpsertLocalUser(context.Background(), sqlcdb.UpsertLocalUserParams{
Email: email,
PasswordHash: string(hashedPassword),
Name: name,
Column4: strings.TrimSpace(authUser.Image),
IsAdmin: total64 == 0,
})
if err != nil {
return User{}, err
}
user := User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
return user, nil
}
@@ -302,8 +314,8 @@ func handleRegister(c *gin.Context) {
return
}
var existing int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
q := sqlcdb.New(db.DB)
existing, err := q.CountUsersByEmail(context.Background(), strings.ToLower(strings.TrimSpace(req.Email)))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
@@ -322,12 +334,20 @@ func handleRegister(c *gin.Context) {
}
// First account owns the platform.
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := q.InsertUserAdmin(context.Background(), sqlcdb.InsertUserAdminParams{
Email: req.Email,
PasswordHash: string(hashedPassword),
Name: req.Name,
IsAdmin: total == 0,
})
user := User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
@@ -365,8 +385,8 @@ func handleCreateUser(c *gin.Context) {
return
}
var existing int
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil {
existing, err := sqlcdb.New(db.DB).CountUsersByEmail(context.Background(), req.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
@@ -390,12 +410,19 @@ func handleCreateUser(c *gin.Context) {
return
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := sqlcdb.New(db.DB).InsertUser(context.Background(), sqlcdb.InsertUserParams{
Email: req.Email,
PasswordHash: string(hashedPassword),
Name: req.Name,
})
user := User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return
@@ -458,10 +485,11 @@ func createBetterAuthUser(c *gin.Context, name string, email string, password st
}
func countLocalUsers(db *database.DB) (int, error) {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
count64, err := sqlcdb.New(db.DB).CountUsers(context.Background())
if err != nil {
return 0, err
}
count := int(count64)
// Better Auth writes to auth_users; a row there only mirrors into users on
// the first authenticated call. Count it too or a second public sign-up
// slips through the bootstrap window. UNION by email avoids double-counting
@@ -489,11 +517,15 @@ func handleGetProfile(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
var user User
err := db.QueryRow(`
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := sqlcdb.New(db.DB).GetUserByID(context.Background(), uuid.MustParse(userID))
user = User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
@@ -521,11 +553,11 @@ func handleUpdateProfile(c *gin.Context) {
}
// Update user profile
_, err := db.Exec(`
UPDATE users
SET name = COALESCE($1, name), avatar_url = COALESCE($2, avatar_url)
WHERE id = $3
`, req.Name, req.AvatarURL, userID)
err := sqlcdb.New(db.DB).UpdateUserProfile(context.Background(), sqlcdb.UpdateUserProfileParams{
Column1: req.Name,
Column2: req.AvatarURL,
ID: uuid.MustParse(userID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
+55 -65
View File
@@ -16,6 +16,7 @@ import (
"containr/internal/build"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"containr/internal/types"
@@ -669,39 +670,22 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
}
now := time.Now().UTC()
_, err := h.db.Exec(
`INSERT INTO builds
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
ON CONFLICT (id) DO UPDATE SET
project_id = EXCLUDED.project_id,
service_id = EXCLUDED.service_id,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
image_name = EXCLUDED.image_name,
image_tag = EXCLUDED.image_tag,
size = EXCLUDED.size,
error = EXCLUDED.error,
log = EXCLUDED.log,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at`,
status.ID,
nullIfEmptyString(status.ProjectID),
nullIfEmptyString(status.ServiceID),
status.Status,
status.Progress,
status.StartedAt,
status.CompletedAt,
status.ImageName,
status.ImageTag,
status.Size,
nullIfEmptyString(status.Error),
status.Log,
jsonOrEmptyObject(metadataRaw),
now,
)
err := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{
ID: status.ID,
ProjectID: nsNullIfEmpty(status.ProjectID),
ServiceID: nsNullIfEmpty(status.ServiceID),
Status: status.Status,
Progress: int32(status.Progress),
StartedAt: status.StartedAt,
CompletedAt: ntPtr(status.CompletedAt),
ImageName: status.ImageName,
ImageTag: status.ImageTag,
Size: status.Size,
Error: nsNullIfEmpty(status.Error),
Log: status.Log,
Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)),
CreatedAt: now,
})
if err != nil && h.isMissingBuildsTable(err) {
return nil
}
@@ -713,18 +697,37 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
return BuildStatusResponse{}, false, nil
}
row := h.db.QueryRow(
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
FROM builds
WHERE id = $1`,
buildID,
)
build, found, err := scanBuildRow(row.Scan)
if err != nil && h.isMissingBuildsTable(err) {
return BuildStatusResponse{}, false, nil
row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
return BuildStatusResponse{}, false, nil
}
return BuildStatusResponse{}, false, err
}
return build, found, err
build := BuildStatusResponse{
ID: row.ID,
ProjectID: row.ProjectID.String,
ServiceID: row.ServiceID.String,
Status: row.Status,
Progress: int(row.Progress),
StartedAt: row.StartedAt.UTC(),
ImageName: row.ImageName,
ImageTag: row.ImageTag,
Size: row.Size,
Error: row.Error.String,
Log: row.Log,
}
if t := timePtrNT(row.CompletedAt); t != nil {
u := t.UTC()
build.CompletedAt = &u
}
if len(row.Metadata) > 0 {
metadata := map[string]string{}
if err := json.Unmarshal(row.Metadata, &metadata); err == nil {
build.Metadata = metadata
}
}
return build, true, nil
}
func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) {
@@ -808,19 +811,11 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
return false
}
now := time.Now().UTC()
result, err := h.db.Exec(
`UPDATE builds
SET status = 'cancelled',
progress = 100,
completed_at = $1,
log = COALESCE(log, '') || $2,
updated_at = $1
WHERE id = $3
AND status NOT IN ('success', 'failed', 'cancelled')`,
now,
h.formatLogLine("Cancellation requested")+"\n",
buildID,
)
affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{
CompletedAt: sql.NullTime{Time: now, Valid: true},
Log: h.formatLogLine("Cancellation requested") + "\n",
ID: buildID,
})
if err != nil {
if h.isMissingBuildsTable(err) {
return false
@@ -828,9 +823,7 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
log.Printf("failed to cancel build %s in database: %v", buildID, err)
return false
}
affected, err := result.RowsAffected()
if err != nil || affected == 0 {
if affected == 0 {
return false
}
@@ -909,12 +902,9 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
return parsed, true, nil
}
func nullIfEmptyString(value string) interface{} {
func nsNullIfEmpty(value string) sql.NullString {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return trimmed
return sql.NullString{String: trimmed, Valid: trimmed != ""}
}
func jsonOrEmptyObject(raw []byte) string {
+18 -23
View File
@@ -1,9 +1,11 @@
package api
import (
"context"
"net/http"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -57,15 +59,12 @@ func contextIsAdmin(c *gin.Context) bool {
// public; unapproved ones are visible to the owner, project members, and
// platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var isApproved bool
var ownerID string
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
q := sqlcdb.New(db.DB)
row, err := q.GetProjectReadAccess(context.Background(), projectID)
if err != nil {
return false, false
}
isApproved, ownerID := row.IsApproved, row.OwnerID
if isApproved || contextIsAdmin(c) {
return true, true
}
@@ -76,11 +75,11 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
userUUID, _ := uuid.Parse(userID)
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
ProjectID: projectID,
UserID: userUUID,
}); err == nil && member {
return true, true
}
return true, false
@@ -91,11 +90,8 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
// project members, and platform admins may reach secret-adjacent data
// such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var ownerID string
err := db.QueryRow(
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
q := sqlcdb.New(db.DB)
ownerID, err := q.GetProjectOwnerText(context.Background(), projectID)
if err != nil {
return false, false
}
@@ -109,11 +105,11 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
userUUID, _ := uuid.Parse(userID)
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
ProjectID: projectID,
UserID: userUUID,
}); err == nil && member {
return true, true
}
return true, false
@@ -121,8 +117,7 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
// serviceProjectID resolves the project that owns a service.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
projectID, err := sqlcdb.New(db.DB).GetServiceProjectID(context.Background(), serviceID)
if err != nil {
return uuid.Nil, false
}
+143 -119
View File
@@ -65,13 +65,47 @@ type UpdateCronJobRequest struct {
Retention int `json:"retention"`
}
// cronJobFrom maps the sqlc row onto the string-ID API model.
func cronJobFrom(j sqlcdb.CronJob) CronJob {
return CronJob{
ID: j.ID.String(),
ProjectID: j.ProjectID.String(),
ServiceID: j.ServiceID.String(),
Name: j.Name,
Schedule: j.Schedule,
Command: j.Command,
Timezone: j.Timezone.String,
Enabled: j.Enabled.Bool,
LastRunAt: timePtrNT(j.LastRunAt),
NextRunAt: timePtrNT(j.NextRunAt),
LastStatus: j.LastStatus.String,
LastOutput: j.LastOutput.String,
Retention: int(j.Retention.Int32),
CreatedAt: j.CreatedAt.Time,
UpdatedAt: j.UpdatedAt.Time,
}
}
// cronExecutionFrom maps the sqlc row onto the string-ID API model.
func cronExecutionFrom(e sqlcdb.CronExecution) CronExecution {
return CronExecution{
ID: e.ID.String(),
CronJobID: e.CronJobID.String(),
StartedAt: e.StartedAt,
FinishedAt: timePtrNT(e.FinishedAt),
Status: e.Status.String,
Output: e.Output.String,
Error: e.Error.String,
}
}
// cronJobProjectID resolves the project a cron job belongs to.
func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(
`SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`,
jobID,
).Scan(&projectID)
id, err := uuid.Parse(jobID)
if err != nil {
return uuid.Nil, false
}
projectID, err := sqlcdb.New(db.DB).GetCronJobProjectID(context.Background(), id)
if err != nil {
return uuid.Nil, false
}
@@ -137,15 +171,14 @@ func handleCreateCronJob(c *gin.Context) {
return
}
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM projects p
JOIN services s ON s.project_id = p.id
WHERE s.id = $1`,
req.ServiceID,
).Scan(&ownerCheck)
svcID, err := uuid.Parse(req.ServiceID)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
ownerCheck, err := sqlcdb.New(db.DB).GetCronOwnerViaService(context.Background(), svcID)
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -178,11 +211,20 @@ func handleCreateCronJob(c *gin.Context) {
UpdatedAt: time.Now(),
}
_, err = db.Exec(
`INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
job.ID, job.ProjectID, job.ServiceID, job.Name, job.Schedule, job.Command, job.Timezone, job.Enabled, job.NextRunAt, job.Retention, job.CreatedAt, job.UpdatedAt,
)
err = sqlcdb.New(db.DB).InsertCronJob(context.Background(), sqlcdb.InsertCronJobParams{
ID: uuid.MustParse(job.ID),
ProjectID: uuid.MustParse(job.ProjectID),
ServiceID: uuid.MustParse(job.ServiceID),
Name: job.Name,
Schedule: job.Schedule,
Command: job.Command,
Timezone: sql.NullString{String: job.Timezone, Valid: true},
Enabled: sql.NullBool{Bool: job.Enabled, Valid: true},
NextRunAt: ntPtr(job.NextRunAt),
Retention: sql.NullInt32{Int32: int32(job.Retention), Valid: true},
CreatedAt: sql.NullTime{Time: job.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: job.UpdatedAt, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"})
@@ -201,22 +243,17 @@ func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var job CronJob
err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
jid, err := uuid.Parse(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
row, err := sqlcdb.New(db.DB).GetCronJob(context.Background(), jid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
job := cronJobFrom(row)
projectID, found := cronJobProjectID(db, jobID)
if !found {
@@ -242,15 +279,14 @@ func handleUpdateCronJob(c *gin.Context) {
return
}
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
jid, err := uuid.Parse(jobID)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
ownerCheck, err := sqlcdb.New(db.DB).GetCronJobOwner(context.Background(), jid)
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -312,21 +348,19 @@ func handleDeleteCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
jid, err := uuid.Parse(jobID)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
ownerCheck, err := sqlcdb.New(db.DB).GetCronJobOwner(context.Background(), jid)
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
_, err = db.Exec("DELETE FROM cron_jobs WHERE id = $1", jobID)
if err != nil {
if err := sqlcdb.New(db.DB).DeleteCronJob(context.Background(), jid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"})
return
}
@@ -350,29 +384,20 @@ func handleGetCronExecutions(c *gin.Context) {
return
}
rows, err := db.Query(
`SELECT id, cron_job_id, started_at, finished_at, status, output, error
FROM cron_executions
WHERE cron_job_id = $1
ORDER BY started_at DESC
LIMIT 100`,
jobID,
)
jid, err := uuid.Parse(jobID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
return
}
rows, err := sqlcdb.New(db.DB).ListCronExecutions(context.Background(), jid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
return
}
defer rows.Close()
var executions []CronExecution
for rows.Next() {
var exec CronExecution
err := rows.Scan(&exec.ID, &exec.CronJobID, &exec.StartedAt, &exec.FinishedAt, &exec.Status, &exec.Output, &exec.Error)
if err != nil {
continue
}
executions = append(executions, exec)
for _, e := range rows {
executions = append(executions, cronExecutionFrom(e))
}
c.JSON(http.StatusOK, gin.H{"executions": executions})
@@ -383,29 +408,33 @@ func handleTriggerCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
jid, err := uuid.Parse(jobID)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
trig, err := sqlcdb.New(db.DB).GetCronJobForTrigger(context.Background(), jid)
if err != nil || (trig.OwnerID.String() != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
job := CronJob{
ServiceID: trig.ServiceID.String(),
Command: trig.Command,
Schedule: trig.Schedule,
Timezone: trig.Timezone.String,
Retention: int(trig.Retention.Int32),
}
execID := uuid.New().String()
now := time.Now()
_, err = db.Exec(
`INSERT INTO cron_executions (id, cron_job_id, started_at, status)
VALUES ($1, $2, $3, $4)`,
execID, jobID, now, "running",
)
_ = sqlcdb.New(db.DB).InsertCronExecution(context.Background(), sqlcdb.InsertCronExecutionParams{
ID: uuid.MustParse(execID),
CronJobID: jid,
StartedAt: now,
Status: sql.NullString{String: "running", Valid: true},
})
dockerClient, _ := c.Get("docker_client")
dockerCli, _ := dockerClient.(*docker.Client)
@@ -458,39 +487,25 @@ func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *dock
}
func runDueCronJobs(db *database.DB, dockerClient *docker.Client) {
rows, err := db.Query(
`SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
FROM cron_jobs
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()`)
q := sqlcdb.New(db.DB)
jobs, err := q.ListDueCronJobs(context.Background())
if err != nil {
log.Printf("cron scheduler: failed to list due jobs: %v", err)
return
}
defer rows.Close()
type dueJob struct {
CronJob
Command string
}
var jobs []dueJob
for rows.Next() {
var j dueJob
if err := rows.Scan(&j.ID, &j.ProjectID, &j.ServiceID, &j.Name, &j.Schedule,
&j.Command, &j.Timezone, &j.Enabled, &j.Retention); err != nil {
continue
}
jobs = append(jobs, j)
}
for _, j := range jobs {
execID := uuid.New().String()
if _, err := db.Exec(
`INSERT INTO cron_executions (id, cron_job_id, started_at, status) VALUES ($1, $2, $3, $4)`,
execID, j.ID, time.Now(), "running"); err != nil {
if err := q.InsertCronExecution(context.Background(), sqlcdb.InsertCronExecutionParams{
ID: uuid.MustParse(execID),
CronJobID: j.ID,
StartedAt: time.Now(),
Status: sql.NullString{String: "running", Valid: true},
}); err != nil {
log.Printf("cron scheduler: failed to record execution for %s: %v", j.ID, err)
continue
}
go executeCronJob(db, dockerClient, j.ID, j.ServiceID, execID, j.Command, j.Schedule, j.Timezone, j.Retention)
go executeCronJob(db, dockerClient, j.ID.String(), j.ServiceID.String(), execID, j.Command, j.Schedule, j.Timezone.String, int(j.Retention.Int32))
}
}
@@ -584,23 +599,32 @@ func executeCronJob(db *database.DB, dockerClient *docker.Client, jobID, service
}
now := time.Now()
db.Exec(
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5`,
now, status, output, errText, execID,
)
q := sqlcdb.New(db.DB)
if eid, err := uuid.Parse(execID); err == nil {
_ = q.FinishCronExecution(context.Background(), sqlcdb.FinishCronExecutionParams{
FinishedAt: sql.NullTime{Time: now, Valid: true},
Status: sql.NullString{String: status, Valid: true},
Output: sql.NullString{String: output, Valid: true},
Error: sql.NullString{String: errText, Valid: true},
ID: eid,
})
}
next, _ := calculateNextRun(schedule, timezone)
db.Exec(
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5`,
now, status, output, next, jobID,
)
if jid, err := uuid.Parse(jobID); err == nil {
_ = q.UpdateCronJobRun(context.Background(), sqlcdb.UpdateCronJobRunParams{
LastRunAt: sql.NullTime{Time: now, Valid: true},
LastStatus: sql.NullString{String: status, Valid: true},
LastOutput: sql.NullString{String: output, Valid: true},
NextRunAt: ntPtr(next),
ID: jid,
})
if retention > 0 {
db.Exec(
`DELETE FROM cron_executions WHERE cron_job_id = $1 AND id NOT IN (
SELECT id FROM cron_executions WHERE cron_job_id = $1
ORDER BY started_at DESC LIMIT $2)`,
jobID, retention,
)
if retention > 0 {
_ = q.TrimCronExecutions(context.Background(), sqlcdb.TrimCronExecutionsParams{
CronJobID: jid,
Limit: int32(retention),
})
}
}
}
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
)
func TestCalculateNextRun(t *testing.T) {
cases := []struct {
name string
schedule string
timezone string
wantErr bool
}{
{"every minute", "* * * * *", "", false},
{"daily at 4am", "0 4 * * *", "UTC", false},
{"weekday run", "0 9 * * 1-5", "Europe/Prague", false},
{"whitespace tolerated", " */15 * * * * ", "", false},
{"empty schedule", "", "", true},
{"bad field count", "* * *", "", true},
{"garbage", "not a cron", "", true},
{"bad timezone", "0 4 * * *", "Mars/Olympus", true},
}
now := time.Now()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
next, err := calculateNextRun(tc.schedule, tc.timezone)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got next=%v", next)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if next == nil {
t.Fatal("expected non-nil next run")
}
if !next.After(now) {
t.Errorf("next run %v is not after %v", next, now)
}
})
}
}
func TestCalculateNextRunTimezone(t *testing.T) {
// CRON_TZ must change the computed instant: 04:00 in a fixed-offset zone
// lands at a different UTC hour than 04:00 UTC.
utcNext, err := calculateNextRun("0 4 * * *", "UTC")
if err != nil {
t.Fatalf("utc: %v", err)
}
tokyoNext, err := calculateNextRun("0 4 * * *", "Asia/Tokyo")
if err != nil {
t.Fatalf("tokyo: %v", err)
}
if utcNext.Equal(*tokyoNext) {
t.Fatalf("timezone had no effect: both %v", utcNext)
}
if !strings.EqualFold(utcNext.Location().String(), "Local") {
// UTC-scheduled runs still land in server-local time representation.
t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext)
}
}
// TestCronLiveInvariants exercises the cron.sql queries on live Postgres:
// insert/read/owner join, execution lifecycle, run bookkeeping, retention trim.
func TestCronLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
jobID := uuid.New()
suffix := strings.ReplaceAll(jobID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "cron-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "cron-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
VALUES ($1, $2, $3, $4, 'web', 'image')`,
serviceID, "cron-"+suffix, projectID, envID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
next, err := calculateNextRun("0 4 * * *", "UTC")
if err != nil {
t.Fatalf("schedule: %v", err)
}
must(q.InsertCronJob(ctx, sqlcdb.InsertCronJobParams{
ID: jobID, ProjectID: projectID, ServiceID: serviceID,
Name: "job-" + suffix, Schedule: "0 4 * * *", Command: "echo hi",
Timezone: sql.NullString{String: "UTC", Valid: true},
Enabled: sql.NullBool{Bool: true, Valid: true},
NextRunAt: ntPtr(next),
}))
job, err := q.GetCronJob(ctx, jobID)
if err != nil || job.Name != "job-"+suffix || job.Command != "echo hi" {
t.Fatalf("job: %v %+v", err, job)
}
owner, err := q.GetCronJobOwner(ctx, jobID)
if err != nil || owner != userID {
t.Fatalf("owner: %v %v", err, owner)
}
owner2, err := q.GetCronOwnerViaService(ctx, serviceID)
if err != nil || owner2 != userID {
t.Fatalf("owner via service: %v %v", err, owner2)
}
pid, err := q.GetCronJobProjectID(ctx, jobID)
if err != nil || pid != projectID {
t.Fatalf("project: %v %v", err, pid)
}
// Trigger-shaped read.
trig, err := q.GetCronJobForTrigger(ctx, jobID)
if err != nil || trig.ServiceID != serviceID || trig.OwnerID != userID {
t.Fatalf("trigger: %v %+v", err, trig)
}
// Execution lifecycle + retention trim.
for i := 0; i < 3; i++ {
execID := uuid.New()
must(q.InsertCronExecution(ctx, sqlcdb.InsertCronExecutionParams{
ID: execID, CronJobID: jobID,
StartedAt: time.Now().Add(time.Duration(i) * time.Second),
Status: sql.NullString{String: "running", Valid: true},
}))
must(q.FinishCronExecution(ctx, sqlcdb.FinishCronExecutionParams{
FinishedAt: sql.NullTime{Time: time.Now(), Valid: true},
Status: sql.NullString{String: "success", Valid: true},
Output: sql.NullString{String: "ok", Valid: true},
Error: sql.NullString{String: "", Valid: true},
ID: execID,
}))
}
execs, err := q.ListCronExecutions(ctx, jobID)
if err != nil || len(execs) != 3 {
t.Fatalf("execs: %v %d", err, len(execs))
}
if execs[0].Status.String != "success" || !execs[0].FinishedAt.Valid {
t.Fatalf("exec row: %+v", execs[0])
}
must(q.TrimCronExecutions(ctx, sqlcdb.TrimCronExecutionsParams{CronJobID: jobID, Limit: 1}))
execs, _ = q.ListCronExecutions(ctx, jobID)
if len(execs) != 1 {
t.Fatalf("trim kept %d, want 1", len(execs))
}
must(q.UpdateCronJobRun(ctx, sqlcdb.UpdateCronJobRunParams{
LastRunAt: sql.NullTime{Time: time.Now(), Valid: true},
LastStatus: sql.NullString{String: "success", Valid: true},
LastOutput: sql.NullString{String: "ok", Valid: true},
NextRunAt: ntPtr(next),
ID: jobID,
}))
job, _ = q.GetCronJob(ctx, jobID)
if job.LastStatus.String != "success" || !job.LastRunAt.Valid {
t.Fatalf("post-run job: %+v", job)
}
must(q.DeleteCronJob(ctx, jobID))
if _, err := q.GetCronJob(ctx, jobID); err == nil {
t.Fatal("deleted job still readable")
}
}
+277 -279
View File
@@ -2,12 +2,14 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
"containr/internal/source"
"containr/internal/types"
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
@@ -37,6 +39,72 @@ type DeploymentModel struct {
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// deploymentRowShape carries the deployment columns shared by the
// generated list/detail/rollback row types.
type deploymentRowShape struct {
ID uuid.UUID
ServiceID uuid.UUID
CommitHash sql.NullString
Status sql.NullString
ImageName sql.NullString
ImageTag sql.NullString
BuildLog sql.NullString
RuntimeLog sql.NullString
Error sql.NullString
StartedAt sql.NullTime
CompletedAt sql.NullTime
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
}
func strPtrNS(v sql.NullString) *string {
if !v.Valid {
return nil
}
s := v.String
return &s
}
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func timePtrNT(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time
return &t
}
func ntPtr(t *time.Time) sql.NullTime {
if t == nil {
return sql.NullTime{}
}
return sql.NullTime{Time: *t, Valid: true}
}
func deploymentModelFrom(r deploymentRowShape) DeploymentModel {
return DeploymentModel{
ID: r.ID,
ServiceID: r.ServiceID,
CommitHash: strPtrNS(r.CommitHash),
Status: r.Status.String,
ImageName: r.ImageName.String,
ImageTag: r.ImageTag.String,
BuildLog: r.BuildLog.String,
RuntimeLog: r.RuntimeLog.String,
Error: strPtrNS(r.Error),
StartedAt: timePtrNT(r.StartedAt),
CompletedAt: timePtrNT(r.CompletedAt),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
type CreateDeploymentRequest struct {
CommitHash string `json:"commit_hash"`
Branch string `json:"branch"`
@@ -84,44 +152,29 @@ func handleGetDeployments(c *gin.Context) {
offset, limit := pageWindow(c, 50, 100)
var total int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
).Scan(&total)
q := sqlcdb.New(db.(*database.DB).DB)
total, _ := q.CountDeploymentsForService(c.Request.Context(), serviceID)
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
serviceID, limit, offset,
)
rows, err := q.ListDeploymentsForService(c.Request.Context(), sqlcdb.ListDeploymentsForServiceParams{
ServiceID: serviceID, Limit: int32(limit), Offset: int32(offset)})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
return
}
defer rows.Close()
var deployments []DeploymentModel
for rows.Next() {
var d DeploymentModel
err := rows.Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
return
}
deployments = append(deployments, d)
deployments := make([]DeploymentModel, 0, len(rows))
for _, r := range rows {
deployments = append(deployments, deploymentModelFrom(deploymentRowShape{
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
}))
}
c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
"pagination": paginationMeta(offset, limit, int(total)),
})
}
@@ -152,51 +205,33 @@ func handleGetRecentDeployments(c *gin.Context) {
offset, limit := pageWindow(c, 10, 50)
var total int
if err := db.(*database.DB).QueryRow(
`SELECT COUNT(*)
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
userID, isAdmin,
).Scan(&total); err != nil {
q := sqlcdb.New(db.(*database.DB).DB)
total, err := q.CountAccessibleDeployments(c.Request.Context(), sqlcdb.CountAccessibleDeploymentsParams{
OwnerID: userID, Column2: isAdmin})
if err != nil {
total = 0
}
rows, err := db.(*database.DB).Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC
LIMIT $3 OFFSET $4`,
userID, isAdmin, limit, offset,
)
rows, err := q.ListRecentAccessibleDeployments(c.Request.Context(), sqlcdb.ListRecentAccessibleDeploymentsParams{
OwnerID: userID, Column2: isAdmin, Limit: int32(limit), Offset: int32(offset)})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
return
}
defer rows.Close()
deployments := []RecentDeployment{}
for rows.Next() {
var d RecentDeployment
if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName,
&d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
return
}
deployments = append(deployments, d)
deployments := make([]RecentDeployment, 0, len(rows))
for _, r := range rows {
deployments = append(deployments, RecentDeployment{
ID: r.ID, ServiceID: r.ServiceID, ServiceName: r.ServiceName,
ProjectName: r.ProjectName, Status: r.Status.String, ImageName: r.ImageName,
StartedAt: timePtrNT(r.StartedAt), CompletedAt: timePtrNT(r.CompletedAt),
CreatedAt: r.CreatedAt.Time,
})
}
c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
"pagination": paginationMeta(offset, limit, int(total)),
})
}
@@ -230,38 +265,27 @@ func handleCreateDeployment(c *gin.Context) {
return
}
var service Service
var projectOwner string
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
)
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
sr, err := q.GetServiceForDeployWithOwner(ctx, serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
service := serviceForDeployFromRow(sqlcdb.GetServiceForDeployRow{
ID: sr.ID, ProjectID: sr.ProjectID, Name: sr.Name, Type: sr.Type,
Status: sr.Status, Image: sr.Image, Command: sr.Command,
Environment: sr.Environment, GitRepo: sr.GitRepo, GitBranch: sr.GitBranch,
BuildPath: sr.BuildPath, Cpu: sr.Cpu, Memory: sr.Memory,
Replicas: sr.Replicas, Port: sr.Port, Domain: sr.Domain,
HealthcheckPath: sr.HealthcheckPath, RestartPolicy: sr.RestartPolicy,
Builder: sr.Builder, CpuReserve: sr.CpuReserve, MemoryReserve: sr.MemoryReserve,
StaticBuildCmd: sr.StaticBuildCmd, StaticDir: sr.StaticDir,
CreatedAt: sr.CreatedAt, UpdatedAt: sr.UpdatedAt,
})
projectOwner := sr.OwnerID
if projectOwner != userID.(string) {
if projectOwner.String() != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -287,12 +311,17 @@ func handleCreateDeployment(c *gin.Context) {
UpdatedAt: now,
}
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
)
err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: d.ID,
ServiceID: d.ServiceID,
Version: fmt.Sprintf("v%d", now.Unix()),
CommitHash: sql.NullString{String: ptrStr(d.CommitHash), Valid: d.CommitHash != nil},
Status: sql.NullString{String: d.Status, Valid: true},
ImageName: sql.NullString{String: d.ImageName, Valid: true},
ImageTag: sql.NullString{String: d.ImageTag, Valid: true},
CreatedAt: sql.NullTime{Time: d.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: d.UpdatedAt, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
@@ -303,20 +332,20 @@ func handleCreateDeployment(c *gin.Context) {
if !exists || engine == nil {
unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server."
completedAt := time.Now()
_, _ = db.(*database.DB).Exec(
`UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3`,
unavailableErr, completedAt, d.ID,
)
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: unavailableErr, Valid: true},
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
ID: d.ID,
})
d.Status = "failed"
d.Error = &unavailableErr
d.CompletedAt = &completedAt
} else {
_, err = db.(*database.DB).Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "building", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: serviceID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"})
return
@@ -331,14 +360,17 @@ func handleCreateDeployment(c *gin.Context) {
})
if pos > 0 {
d.Status = "queued"
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
now := time.Now()
_ = q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: d.ID,
})
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: serviceID,
})
}
}
@@ -380,6 +412,7 @@ func runDeploymentAndSyncWithImage(
) {
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
defer cancel()
q := sqlcdb.New(db.DB)
// The checkout and any shipped artifacts die with this deployment.
var artifactIDs []string
@@ -392,10 +425,11 @@ func runDeploymentAndSyncWithImage(
}()
failDeploy := func(msg string) {
failedAt := time.Now()
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
msg, failedAt, dbDeployment.ID,
)
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: msg, Valid: true},
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: dbDeployment.ID,
})
}
sourcePath := strings.TrimSpace(service.BuildPath)
@@ -423,11 +457,10 @@ func runDeploymentAndSyncWithImage(
replicas = 1
}
var publishedPort int32
// Best effort: reuse the host port from the last live deployment so public
// URLs survive redeploys. Column exists post-migration; older DBs get
// ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
publishedPort, _ := q.GetServicePublishedPort(ctx, service.ID)
publicPort := int32(service.Port)
if service.Builder == "static" && publicPort == 0 {
@@ -451,7 +484,9 @@ func runDeploymentAndSyncWithImage(
// remote builds package it into a context artifact the node builds.
if service.GitRepo != "" && imageOverride == "" {
var ownerID string
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil {
ownerID = o.String()
}
branch := req.Branch
if branch == "" {
branch = service.GitBranch
@@ -535,7 +570,9 @@ func runDeploymentAndSyncWithImage(
}
} else {
var ownerID string
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil {
ownerID = o.String()
}
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
@@ -605,10 +642,11 @@ func runDeploymentAndSyncWithImage(
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
failedAt := time.Now()
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
msg, failedAt, dbDeployment.ID,
)
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: msg, Valid: true},
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: dbDeployment.ID,
})
return
}
@@ -616,16 +654,16 @@ func runDeploymentAndSyncWithImage(
if err != nil {
failedAt := time.Now()
failure := "Failed to start deployment engine: " + err.Error()
_, _ = db.Exec(
`UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3`,
failure, failedAt, dbDeployment.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
failedAt, service.ID,
)
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: failure, Valid: true},
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: dbDeployment.ID,
})
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "failed", Valid: true},
UpdatedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: service.ID,
})
return
}
@@ -640,16 +678,17 @@ func runDeploymentAndSyncWithImage(
if errors.Is(ctx.Err(), context.Canceled) {
finalStatus, finalErr = "cancelled", "Deployment cancelled"
}
_, _ = db.Exec(
`UPDATE deployments
SET status = $1, error = $2, completed_at = $3, updated_at = $3
WHERE id = $4`,
finalStatus, finalErr, failedAt, dbDeployment.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
finalStatus, failedAt, service.ID,
)
_ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{
Status: sql.NullString{String: finalStatus, Valid: true},
Error: sql.NullString{String: finalErr, Valid: true},
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: dbDeployment.ID,
})
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: finalStatus, Valid: true},
UpdatedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: service.ID,
})
return
case <-syncTicker.C:
current, getErr := engine.GetDeployment(engineDeployment.ID)
@@ -660,49 +699,36 @@ func runDeploymentAndSyncWithImage(
dbStatus := mapEngineStatusToDBStatus(current.Status)
imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag)
var dbError interface{}
if current.Error != "" {
dbError = current.Error
}
_, _ = db.Exec(
`UPDATE deployments
SET status = $1,
image_name = $2,
image_tag = $3,
build_log = $4,
runtime_log = $5,
error = $6,
started_at = $7,
completed_at = $8,
updated_at = $9
WHERE id = $10`,
dbStatus,
imageName,
imageTag,
current.BuildLog,
current.DeployLog,
dbError,
current.StartedAt,
current.CompletedAt,
time.Now(),
dbDeployment.ID,
)
now := time.Now()
_ = q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
Status: sql.NullString{String: dbStatus, Valid: true},
ImageName: sql.NullString{String: imageName, Valid: true},
ImageTag: sql.NullString{String: imageTag, Valid: true},
BuildLog: sql.NullString{String: current.BuildLog, Valid: true},
RuntimeLog: sql.NullString{String: current.DeployLog, Valid: true},
Error: sql.NullString{String: current.Error, Valid: current.Error != ""},
StartedAt: ntPtr(current.StartedAt),
CompletedAt: ntPtr(current.CompletedAt),
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: dbDeployment.ID,
})
switch dbStatus {
case "deployed":
_, _ = db.Exec(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "running", Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: service.ID,
})
insertUserNotification(db, userID, "deployment", "Deployment succeeded",
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
return
case "failed":
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "failed", Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: service.ID,
})
body := fmt.Sprintf("Service %s failed to deploy.", service.Name)
if current.Error != "" {
body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error)
@@ -754,21 +780,16 @@ func handleCancelDeployment(c *gin.Context) {
return
}
var status, owner string
var serviceID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&status, &serviceID, &owner)
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
access, err := q.GetDeploymentAccess(ctx, deploymentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
return
}
if owner != userID.(string) && !contextIsAdmin(c) {
serviceID := access.ServiceID
status := access.Status.String
if access.OwnerID.String() != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
return
}
@@ -782,14 +803,17 @@ func handleCancelDeployment(c *gin.Context) {
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
case deployqueue.WasQueued:
now := time.Now()
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
now, deploymentID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
now, serviceID,
)
_ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{
Status: sql.NullString{String: "cancelled", Valid: true},
Error: sql.NullString{String: "Deployment cancelled", Valid: true},
CompletedAt: sql.NullTime{Time: now, Valid: true},
ID: deploymentID,
})
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "cancelled", Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: serviceID,
})
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
case deployqueue.WasActive:
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
@@ -803,9 +827,15 @@ func splitImageReference(image, fallbackTag string) (string, string) {
return "", fallbackTag
}
// Digest-pinned refs (name[:tag]@sha256:…) carry no usable tag — the
// colon lives inside the digest and must not be split.
if strings.Contains(image, "@") {
return image, fallbackTag
}
lastSlash := strings.LastIndex(image, "/")
lastColon := strings.LastIndex(image, ":")
if lastColon > lastSlash && !strings.Contains(image[lastColon:], "@") {
if lastColon > lastSlash {
return image[:lastColon], image[lastColon+1:]
}
@@ -826,27 +856,18 @@ func handleGetDeployment(c *gin.Context) {
return
}
var d DeploymentModel
var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &projectID,
)
r, err := sqlcdb.New(db.(*database.DB).DB).GetDeploymentWithProject(c.Request.Context(), deploymentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
projectID := r.ProjectID
d := deploymentModelFrom(deploymentRowShape{
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
@@ -876,33 +897,23 @@ func handleRollbackDeployment(c *gin.Context) {
return
}
var targetDeployment DeploymentModel
var serviceID uuid.UUID
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status,
&targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog,
&targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt,
&targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt,
&ownerCheck,
)
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
r, err := q.GetDeploymentForRollback(ctx, deploymentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
serviceID := r.ServiceID
targetDeployment := deploymentModelFrom(deploymentRowShape{
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
ownerCheck := r.OwnerID
if ownerCheck != userID.(string) && !contextIsAdmin(c) {
if ownerCheck.String() != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -925,23 +936,28 @@ func handleRollbackDeployment(c *gin.Context) {
UpdatedAt: now,
}
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status,
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
)
err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: rollback.ID,
ServiceID: rollback.ServiceID,
Version: fmt.Sprintf("rollback-%d", now.Unix()),
CommitHash: sql.NullString{String: ptrStr(rollback.CommitHash), Valid: rollback.CommitHash != nil},
Status: sql.NullString{String: rollback.Status, Valid: true},
ImageName: sql.NullString{String: rollback.ImageName, Valid: true},
ImageTag: sql.NullString{String: rollback.ImageTag, Valid: true},
CreatedAt: sql.NullTime{Time: rollback.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: rollback.UpdatedAt, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
return
}
_, err = db.(*database.DB).Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "building", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: serviceID,
})
// Real rollback: redeploy the target deployment's image.
engineValue, _ := c.Get("deployment_engine")
@@ -957,36 +973,18 @@ func handleRollbackDeployment(c *gin.Context) {
if engine == nil {
reason = "Deployment engine unavailable. Docker may not be configured on this server."
}
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
reason, completedAt, rollbackID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
completedAt, serviceID,
)
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: reason, Valid: true},
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
ID: rollbackID,
})
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "failed", Valid: true},
UpdatedAt: sql.NullTime{Time: completedAt, Valid: true},
ID: serviceID,
})
} else {
var service Service
_ = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at
FROM services s WHERE s.id = $1`, serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
service, _ := loadServiceForDeploy(db.(*database.DB), serviceID)
rollbackReq := CreateDeploymentRequest{
CommitHash: func() string {
@@ -0,0 +1,46 @@
package api
import "testing"
func TestSplitImageReference(t *testing.T) {
cases := []struct {
name string
image string
fallback string
wantImage string
wantTag string
}{
{"empty uses fallback tag", "", "latest", "", "latest"},
{"bare image", "nginx", "latest", "nginx", "latest"},
{"tagged image", "nginx:1.27", "latest", "nginx", "1.27"},
{"registry with port untagged", "registry.local:5000/app", "latest", "registry.local:5000/app", "latest"},
{"registry with port tagged", "registry.local:5000/app:v2", "latest", "registry.local:5000/app", "v2"},
{"digest stays whole", "nginx@sha256:abc123", "latest", "nginx@sha256:abc123", "latest"},
{"tag and digest", "nginx:1.27@sha256:abc", "latest", "nginx:1.27@sha256:abc", "latest"},
{"namespaced tagged", "ghcr.io/owner/repo:dev", "latest", "ghcr.io/owner/repo", "dev"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
img, tag := splitImageReference(tc.image, tc.fallback)
if img != tc.wantImage || tag != tc.wantTag {
t.Fatalf("splitImageReference(%q, %q) = (%q, %q), want (%q, %q)",
tc.image, tc.fallback, img, tag, tc.wantImage, tc.wantTag)
}
})
}
}
func TestMapEngineStatusToDBStatus(t *testing.T) {
cases := map[string]string{
"running": "deployed",
"building": "building",
"failed": "failed",
"cancelled": "cancelled",
"queued": "queued",
}
for in, want := range cases {
if got := mapEngineStatusToDBStatus(in); got != want {
t.Fatalf("mapEngineStatusToDBStatus(%q) = %q, want %q", in, got, want)
}
}
}
+124 -42
View File
@@ -3,6 +3,8 @@ package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net"
"net/http"
"regexp"
@@ -10,6 +12,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -42,19 +45,26 @@ func validHostname(d string) bool {
}
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
rows, err := db.Query(
`SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
rows, err := sqlcdb.New(db.DB).ListServiceDomains(context.Background(), serviceID)
if err != nil {
return nil
}
defer rows.Close()
out := []ServiceDomain{}
for rows.Next() {
var d ServiceDomain
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil {
out = append(out, d)
out := make([]ServiceDomain, 0, len(rows))
for _, r := range rows {
d := ServiceDomain{
ID: r.ID,
ServiceID: r.ServiceID,
Domain: r.Domain,
IsDefault: r.IsDefault,
CertType: r.CertType,
CertStatus: r.CertStatus,
CreatedAt: r.CreatedAt,
}
if r.LastCheckedAt.Valid {
t := r.LastCheckedAt.Time
d.LastCheckedAt = &t
}
out = append(out, d)
}
return out
}
@@ -68,30 +78,95 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
if len(out) == 0 && fallback != "" {
out = []string{fallback}
}
if len(out) == 0 {
out = serviceAutoDomains(db, serviceID)
}
return out
}
// dnsLabel normalizes a service name into a DNS label: lowercase alnum
// plus single dashes, never leading/trailing, ≤63 chars.
func dnsLabel(name string) string {
var b strings.Builder
lastWasSep := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastWasSep = false
continue
}
if !lastWasSep && b.Len() > 0 {
b.WriteByte('-')
lastWasSep = true
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// serviceAutoDomains generates <service>.<node-default-domain> hostnames
// for services with no explicit domains. Pinned services take their node's
// base domain; spread services union every eligible node's base domain.
// Local services get nothing — there is no instance-level base domain yet.
func serviceAutoDomains(db *database.DB, serviceID uuid.UUID) []string {
ctx := context.Background()
q := sqlcdb.New(db.DB)
brief, err := q.GetServicePlacementBrief(ctx, serviceID)
if err != nil {
return nil
}
label := dnsLabel(brief.Name)
if label == "" {
return nil
}
var bases []string
nodeID := brief.NodeID
spread := brief.Spread
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil && a.DefaultDomain != "" {
bases = []string{a.DefaultDomain}
}
case spread:
var err error
bases, err = q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, serviceID))))
if err != nil {
bases = nil
}
}
out := make([]string, 0, len(bases))
for _, base := range bases {
out = append(out, label+"."+base)
}
return out
}
// syncDefaultDomain writes the is_default row back to services.domain so
// legacy reads (CLI tables, public_url) keep working.
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
var domain string
err := db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain)
ctx := context.Background()
q := sqlcdb.New(db.DB)
domain, err := q.GetServiceDomainDefault(ctx, serviceID)
if err == sql.ErrNoRows {
err = db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
domain, err = q.GetServiceDomainFirst(ctx, serviceID)
}
if err == sql.ErrNoRows {
domain = ""
}
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID)
_ = q.SetServiceLegacyDomain(ctx, sqlcdb.SetServiceLegacyDomainParams{Domain: domain, ID: serviceID})
}
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
_ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID).
Scan(&maintenance, &basicAuth)
return maintenance, basicAuth
row, err := sqlcdb.New(db.DB).GetServiceAccess(context.Background(), serviceID)
if err != nil {
return false, ""
}
return row.MaintenanceMode, row.BasicAuthUsers
}
// maintenanceURL is the redirect target for maintenance mode — the public
@@ -136,11 +211,12 @@ func basicAuthUsernames(encoded string) []string {
// serviceWriteAccess mirrors the service update handler's access check.
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
userID, _ := c.Get("user_id")
var ok bool
err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`,
serviceID, userID, contextIsAdmin(c)).Scan(&ok)
uid, _ := uuid.Parse(fmt.Sprint(userID))
ok, err := sqlcdb.New(db.DB).ServiceWriteAccess(context.Background(), sqlcdb.ServiceWriteAccessParams{
ID: serviceID,
OwnerID: uid,
Column3: contextIsAdmin(c),
})
return err == nil && ok
}
@@ -187,9 +263,11 @@ func handleAddServiceDomain(c *gin.Context) {
return
}
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
// First domain on a service becomes the default automatically.
var count int
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
count, _ := q.CountServiceDomains(ctx, serviceID)
makeDefault := req.IsDefault || count == 0
tx, err := db.(*database.DB).Begin()
@@ -198,19 +276,18 @@ func handleAddServiceDomain(c *gin.Context) {
return
}
defer tx.Rollback()
qtx := q.WithTx(tx)
if makeDefault {
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil {
if err := qtx.ClearServiceDomainDefault(ctx, serviceID); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
}
var inserted ServiceDomain
err = tx.QueryRow(
`INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`,
serviceID, domain, makeDefault).
Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
row, err := qtx.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID,
Domain: domain,
IsDefault: makeDefault,
})
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
@@ -224,7 +301,15 @@ func handleAddServiceDomain(c *gin.Context) {
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusCreated, gin.H{"domain": inserted})
c.JSON(http.StatusCreated, gin.H{"domain": ServiceDomain{
ID: row.ID,
ServiceID: row.ServiceID,
Domain: row.Domain,
IsDefault: row.IsDefault,
CertType: row.CertType,
CertStatus: row.CertStatus,
CreatedAt: row.CreatedAt,
}})
}
func handleDeleteServiceDomain(c *gin.Context) {
@@ -247,9 +332,8 @@ func handleDeleteServiceDomain(c *gin.Context) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID)
affected, _ := res.RowsAffected()
affected, err := sqlcdb.New(db.(*database.DB).DB).DeleteServiceDomain(
c.Request.Context(), sqlcdb.DeleteServiceDomainParams{ID: domainID, ServiceID: serviceID})
if err != nil || affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
@@ -278,14 +362,12 @@ func handleSetDefaultDomain(c *gin.Context) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`,
domainID, serviceID)
affected, err := sqlcdb.New(db.(*database.DB).DB).SetServiceDomainDefault(
c.Request.Context(), sqlcdb.SetServiceDomainDefaultParams{ID: domainID, ServiceID: serviceID})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
@@ -353,7 +435,7 @@ func handleCheckServiceDomains(c *gin.Context) {
r.Status = "ok"
}
results = append(results, r)
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID)
_ = sqlcdb.New(dbh.DB).TouchServiceDomainChecked(c.Request.Context(), d.ID)
}
c.JSON(http.StatusOK, gin.H{"domains": results})
}
+601
View File
@@ -0,0 +1,601 @@
package api
import (
"context"
"database/sql"
"os"
"strings"
"testing"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
)
func testDSNString(t *testing.T) string {
t.Helper()
dsn := os.Getenv("METRICS_TEST_DSN")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
t.Skip("METRICS_TEST_DSN not set")
}
return dsn
}
func TestDNSLabel(t *testing.T) {
cases := map[string]string{
"my-app": "my-app",
"My App_2": "my-app-2",
" web ": "web",
"__weird__name": "weird-name",
"---": "",
"": "",
}
for in, want := range cases {
if got := dnsLabel(in); got != want {
t.Errorf("dnsLabel(%q) = %q, want %q", in, got, want)
}
}
}
func TestDNSLabelLengthCap(t *testing.T) {
long := "a" + string(make([]byte, 0)) + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if got := dnsLabel(long); len(got) > 63 {
t.Fatalf("label over 63 chars: %d", len(got))
}
}
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL). Verifies the generated domain queries preserve the
// default-domain and legacy services.domain invariants.
func TestServiceDomainsLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "domtest-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "domtest-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
VALUES ($1, 'domtest-`+suffix+`', $2, $3, 'web', 'image')`,
serviceID, projectID, envID)
must(err)
defer func() {
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
}()
d1, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID, Domain: "a-" + suffix + ".example.com", IsDefault: true})
if err != nil {
t.Fatalf("create d1: %v", err)
}
d2, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID, Domain: "b-" + suffix + ".example.com", IsDefault: false})
if err != nil {
t.Fatalf("create d2: %v", err)
}
syncDefaultDomain(db, serviceID)
var legacy string
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy: %v", err)
}
if legacy != d1.Domain {
t.Fatalf("legacy domain = %q, want %q", legacy, d1.Domain)
}
// Switch default: exactly one row stays default, legacy follows.
if err := q.ClearServiceDomainDefault(ctx, serviceID); err != nil {
t.Fatalf("clear: %v", err)
}
if _, err := q.SetServiceDomainDefault(ctx, sqlcdb.SetServiceDomainDefaultParams{
ID: d2.ID, ServiceID: serviceID}); err != nil {
t.Fatalf("set default: %v", err)
}
syncDefaultDomain(db, serviceID)
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy2: %v", err)
}
if legacy != d2.Domain {
t.Fatalf("legacy after switch = %q, want %q", legacy, d2.Domain)
}
doms := loadServiceDomains(db, serviceID)
if len(doms) != 2 || !doms[0].IsDefault || doms[0].Domain != d2.Domain {
t.Fatalf("list order wrong: %+v", doms)
}
// Deleting the default falls back to the remaining domain.
if _, err := q.DeleteServiceDomain(ctx, sqlcdb.DeleteServiceDomainParams{
ID: d2.ID, ServiceID: serviceID}); err != nil {
t.Fatalf("delete d2: %v", err)
}
syncDefaultDomain(db, serviceID)
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy3: %v", err)
}
if legacy != d1.Domain {
t.Fatalf("legacy after delete = %q, want %q", legacy, d1.Domain)
}
}
// Live clone test: cloneServiceRow must copy config, domains, and variables
// verbatim, start stopped, and land in the target project/environment.
func TestCloneServiceRowLive(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "clone-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "clone-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type,
image_name, port, volumes, traefik_labels, maintenance_mode)
VALUES ($1, $2, $3, $4, 'web', 'image', 'traefik/whoami:latest', 80,
'[{"mount":"/data"}]'::jsonb,
'{"middlewares.rl.ratelimit.average":"100"}'::jsonb, true)`,
serviceID, "clonesrc-"+suffix, projectID, envID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
if _, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID, Domain: "src-" + suffix + ".example.com", IsDefault: true}); err != nil {
t.Fatalf("seed domain: %v", err)
}
if _, err := db.Exec(`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
VALUES ($1, $2, 'FOO', 'bar', false)`, uuid.New(), serviceID); err != nil {
t.Fatalf("seed var: %v", err)
}
cloneID, err := cloneServiceRow(db, serviceID, projectID, "clone-"+suffix, "")
if err != nil {
t.Fatalf("clone: %v", err)
}
var status, imageName, env string
var maintenance bool
var volumes, labels []byte
if err := db.QueryRow(`SELECT status, image_name, COALESCE(environment,''), maintenance_mode,
volumes, traefik_labels FROM services WHERE id = $1`, cloneID).
Scan(&status, &imageName, &env, &maintenance, &volumes, &labels); err != nil {
t.Fatalf("read clone: %v", err)
}
if status != "stopped" || imageName != "traefik/whoami:latest" || env != "production" {
t.Fatalf("clone mismatch: %v %v %v", status, imageName, env)
}
if !maintenance || string(volumes) == "[]" || string(labels) == "{}" {
t.Fatalf("clone dropped config: maint=%v vol=%s labels=%s", maintenance, volumes, labels)
}
doms := loadServiceDomains(db, cloneID)
if len(doms) != 1 || doms[0].Domain != "src-"+suffix+".example.com" || !doms[0].IsDefault {
t.Fatalf("domains not cloned: %+v", doms)
}
var varVal string
if err := db.QueryRow(`SELECT value FROM environment_variables WHERE service_id = $1 AND key = 'FOO'`,
cloneID).Scan(&varVal); err != nil || varVal != "bar" {
t.Fatalf("vars not cloned: %v %q", err, varVal)
}
}
// Live preview-environment test: generated queries must preserve the
// access-check join, the status-sync CASE, and the sweep listing.
func TestPreviewEnvironmentsLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
srcID := uuid.New()
cloneID := uuid.New()
previewID := uuid.New()
suffix := strings.ReplaceAll(previewID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "prev-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "prev-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
for _, p := range [][3]interface{}{{srcID, "src-" + suffix, "running"}, {cloneID, "clone-" + suffix, "running"}} {
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, status)
VALUES ($1, $2, $3, $4, 'web', 'image', $5)`,
p[0], p[1], projectID, envID, p[2])
must(err)
}
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
must(q.CreatePreviewEnvironment(ctx, sqlcdb.CreatePreviewEnvironmentParams{
ID: previewID,
ProjectID: projectID,
ServiceID: srcID,
PreviewServiceID: uuid.NullUUID{UUID: cloneID, Valid: true},
BranchName: "feat-" + suffix,
Environment: "preview-feat-" + suffix,
Status: "building",
ExpiresAt: time.Now().Add(-time.Hour), // already past TTL → sweepable
}))
// List + service join.
rows, err := q.ListPreviewEnvironmentsForProject(ctx, projectID)
if err != nil || len(rows) != 1 {
t.Fatalf("list: %v %d", err, len(rows))
}
if rows[0].ID != previewID || !rows[0].SvcID.Valid || rows[0].ServiceName.String != "src-"+suffix {
t.Fatalf("list row: %+v", rows[0])
}
// Access-check join: owner reads; a stranger does not.
if _, err := q.GetPreviewEnvironment(ctx, sqlcdb.GetPreviewEnvironmentParams{
ID: previewID, OwnerID: userID}); err != nil {
t.Fatalf("owner read: %v", err)
}
if _, err := q.GetPreviewEnvironment(ctx, sqlcdb.GetPreviewEnvironmentParams{
ID: previewID, OwnerID: uuid.New()}); err != nil {
t.Fatalf("approved project should be public-read: %v", err)
}
// Status sync folds clone's 'running' into the preview row.
if err := q.SyncPreviewStatuses(ctx); err != nil {
t.Fatalf("sync: %v", err)
}
var status string
if err := db.QueryRow(`SELECT status FROM preview_environments WHERE id = $1`, previewID).Scan(&status); err != nil {
t.Fatalf("read status: %v", err)
}
if status != "running" {
t.Fatalf("status after sync = %q, want running", status)
}
// TTL sweep picks it up (expired at insert), marks expired.
sweep, err := q.ListSweepablePreviews(ctx)
if err != nil {
t.Fatalf("sweep list: %v", err)
}
found := false
for _, s := range sweep {
if s.ID == previewID && s.PreviewServiceID.UUID == cloneID {
found = true
}
}
if !found {
t.Fatalf("sweepable list missing preview: %+v", sweep)
}
must(q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{Status: "expired", ID: previewID}))
sweep, _ = q.ListSweepablePreviews(ctx)
for _, s := range sweep {
if s.ID == previewID {
t.Fatal("expired preview still sweepable")
}
}
// Owner lookup and delete.
owner, err := q.GetPreviewEnvironmentOwner(ctx, previewID)
if err != nil || owner != userID {
t.Fatalf("owner: %v %v", owner, err)
}
must(q.DeletePreviewEnvironment(ctx, previewID))
}
// Live deployment lifecycle: generated queries preserve list ordering,
// the access join, and the status-transition writes.
func TestDeploymentsLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
deployID := uuid.New()
suffix := strings.ReplaceAll(deployID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "dep-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "dep-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, status)
VALUES ($1, $2, $3, $4, 'web', 'image', 'running')`,
serviceID, "dep-"+suffix, projectID, envID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: deployID,
ServiceID: serviceID,
Version: "v1-" + suffix,
Status: sql.NullString{String: "pending", Valid: true},
}))
total, err := q.CountDeploymentsForService(ctx, serviceID)
if err != nil || total != 1 {
t.Fatalf("count: %v %d", err, total)
}
rows, err := q.ListDeploymentsForService(ctx, sqlcdb.ListDeploymentsForServiceParams{
ServiceID: serviceID, Limit: 10, Offset: 0})
if err != nil || len(rows) != 1 || rows[0].ID != deployID {
t.Fatalf("list: %v %+v", err, rows)
}
// Access join returns owner.
access, err := q.GetDeploymentAccess(ctx, deployID)
if err != nil || access.OwnerID != userID || access.ServiceID != serviceID {
t.Fatalf("access: %v %+v", err, access)
}
// Recent feed honors the access predicate.
feed, err := q.ListRecentAccessibleDeployments(ctx, sqlcdb.ListRecentAccessibleDeploymentsParams{
OwnerID: userID, Limit: 10, Offset: 0})
if err != nil {
t.Fatalf("feed: %v", err)
}
found := false
for _, d := range feed {
if d.ID == deployID && d.ServiceName == "dep-"+suffix {
found = true
}
}
if !found {
t.Fatalf("feed missing deployment: %+v", feed)
}
// Status transitions.
must(q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
Status: sql.NullString{String: "queued", Valid: true}, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: deployID}))
must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
Status: sql.NullString{String: "deployed", Valid: true},
ImageName: sql.NullString{String: "nginx", Valid: true},
ImageTag: sql.NullString{String: "alpine", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: deployID,
}))
r, err := q.GetDeploymentWithProject(ctx, deployID)
if err != nil {
t.Fatalf("get: %v", err)
}
if r.Status.String != "deployed" || r.ImageName.String != "nginx" || r.ProjectID != projectID {
t.Fatalf("post-sync row: %+v", r)
}
must(q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: "boom", Valid: true},
CompletedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: deployID,
}))
r, _ = q.GetDeploymentWithProject(ctx, deployID)
if r.Status.String != "failed" || r.Error.String != "boom" {
t.Fatalf("post-fail row: %+v", r)
}
}
// TestRuntimeLiveInvariants exercises the runtime.sql queries: the
// legacy-column COALESCE chain in GetServiceRuntimeWithOwner, the
// deployed-image concat, and the conditional published-port update.
func TestRuntimeLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "rt-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "rt-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
// Only legacy columns populated — the COALESCE chain must surface them.
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type,
source_url, image_name, start_command, port) VALUES ($1, $2, $3, $4, 'worker', 'git', $5, $6, 'run.sh', 8080)`,
serviceID, "rt-"+suffix, projectID, envID, "https://git.example/x.git", "repo/img:latest")
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
row, err := q.GetServiceRuntimeWithOwner(ctx, serviceID)
if err != nil {
t.Fatalf("runtime row: %v", err)
}
if row.Type != "worker" || row.Image != "repo/img:latest" || row.Command != "run.sh" ||
row.GitRepo != "https://git.example/x.git" || row.OwnerID != userID {
t.Fatalf("COALESCE chain lost legacy columns: %+v", row)
}
svc := serviceFromRuntimeRow(row)
if svc.Type != "worker" || svc.Image != "repo/img:latest" || svc.Port != 8080 {
t.Fatalf("mapped service wrong: %+v", svc)
}
// Sibling refs + port lookup.
refs, err := q.ListProjectServiceRefs(ctx, projectID)
if err != nil || len(refs) != 1 || refs[0].Name != "rt-"+suffix {
t.Fatalf("refs: %v %+v", err, refs)
}
port, err := q.GetServicePort(ctx, serviceID)
if err != nil || port != 8080 {
t.Fatalf("port: %v %d", err, port)
}
// Variables round-trip.
_, err = db.Exec(`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
VALUES (gen_random_uuid(), $1, 'API_KEY', 'shh', true), (gen_random_uuid(), $1, 'PLAIN', 'p', false)`,
serviceID)
must(err)
vars, err := q.ListServiceVariablesWithSecret(ctx, serviceID)
if err != nil || len(vars) != 2 {
t.Fatalf("vars: %v %+v", err, vars)
}
vals, err := q.ListServiceVariableValues(ctx, serviceID)
if err != nil || len(vals) != 2 {
t.Fatalf("vals: %v %+v", err, vals)
}
got, err := q.GetServiceVariableValue(ctx, sqlcdb.GetServiceVariableValueParams{ServiceID: serviceID, Key: "API_KEY"})
if err != nil || got != "shh" {
t.Fatalf("var value: %v %q", err, got)
}
// Node agent listing through container_instances.
_, err = db.Exec(`INSERT INTO node_agents (id, name, hostname, ip_address, port) VALUES ($1, $2, 'h', '127.0.0.1', 9999)`,
"agent-"+suffix, "agent-"+suffix)
must(err)
_, err = db.Exec(`INSERT INTO container_instances (id, name, image, project_id, service_id, node_agent_id)
VALUES ($1, 'c', 'img', $2, $3, $4)`,
"ci-"+suffix, projectID.String(), serviceID.String(), "agent-"+suffix)
must(err)
agents, err := q.ListNodeAgentsForService(ctx, serviceID.String())
if err != nil || len(agents) != 1 || agents[0] != "agent-"+suffix {
t.Fatalf("agents: %v %+v", err, agents)
}
// Last deployed image: only 'deployed' rows, latest first.
d1, d2 := uuid.New(), uuid.New()
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: d1, ServiceID: serviceID, Version: "v1",
Status: sql.NullString{String: "failed", Valid: true},
}))
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: d2, ServiceID: serviceID, Version: "v2",
Status: sql.NullString{String: "deployed", Valid: true},
}))
must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
Status: sql.NullString{String: "deployed", Valid: true},
ImageName: sql.NullString{String: "app", Valid: true},
ImageTag: sql.NullString{String: "v2", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: d2,
}))
img, err := q.GetLastDeployedImage(ctx, serviceID)
if err != nil || img != "app:v2" {
t.Fatalf("last image: %v %q", err, img)
}
// Conditional published-port update: writes once, no-ops on repeat.
must(q.SetServicePublishedPort(ctx, sqlcdb.SetServicePublishedPortParams{PublishedPort: 30001, ID: serviceID}))
p, err := q.GetServicePublishedPort(ctx, serviceID)
if err != nil || p != 30001 {
t.Fatalf("published port: %v %d", err, p)
}
must(q.SetServiceReplicas(ctx, sqlcdb.SetServiceReplicasParams{
Replicas: 3, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: serviceID}))
row, _ = q.GetServiceRuntimeWithOwner(ctx, serviceID)
if row.Replicas != 3 {
t.Fatalf("replicas: %+v", row)
}
// Sleeper reads: candidate sweep + domain/status text lookups.
_, err = db.Exec(`UPDATE services SET sleep_enabled = true, domain = 'rt.example.com', status = 'running' WHERE id = $1`, serviceID)
must(err)
cands, err := q.ListSleepCandidates(ctx)
if err != nil {
t.Fatalf("sleep candidates: %v", err)
}
var mine *sqlcdb.ListSleepCandidatesRow
for i := range cands {
if cands[i].ID == serviceID {
mine = &cands[i]
}
}
if mine == nil || mine.SleepIdleMinutes != 15 {
t.Fatalf("candidate missing/idle wrong: %+v", mine)
}
dom, err := q.GetServiceDomainText(ctx, serviceID)
if err != nil || dom != "rt.example.com" {
t.Fatalf("domain text: %v %q", err, dom)
}
st, err := q.GetServiceStatusText(ctx, serviceID)
if err != nil || st != "running" {
t.Fatalf("status text: %v %q", err, st)
}
}
+140
View File
@@ -0,0 +1,140 @@
package api
// Project environments — the named lanes services deploy into
// (production/development/preview are seeded; custom names allowed).
import (
"context"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
var envNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$`)
type environmentDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ServiceCount int64 `json:"service_count"`
}
func toEnvironmentDTO(id uuid.UUID, name string, projectID uuid.UUID, created, updated time.Time, count int64) environmentDTO {
return environmentDTO{ID: id, Name: name, ProjectID: projectID, CreatedAt: created, UpdatedAt: updated, ServiceCount: count}
}
// handleListEnvironments returns the project's environments with live
// service counts — the data behind the env switcher.
func handleListEnvironments(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
return
}
if exists, allowed := projectReadAccess(c, db, projectID); !exists || !allowed {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Project not found")
return
}
rows, err := sqlcdb.New(db.DB).ListProjectEnvironments(context.Background(), projectID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list environments")
return
}
envs := make([]environmentDTO, 0, len(rows))
for _, r := range rows {
envs = append(envs, toEnvironmentDTO(r.ID, r.Name, r.ProjectID, r.CreatedAt.Time, r.UpdatedAt.Time, r.ServiceCount))
}
c.JSON(http.StatusOK, gin.H{"environments": envs})
}
// handleCreateEnvironment adds a named environment lane to the project.
func handleCreateEnvironment(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
return
}
userID, exists := c.Get("user_id")
if !exists || !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
return
}
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if !envNamePattern.MatchString(name) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Name must be lowercase alphanumeric with dashes, 1-50 chars")
return
}
q := sqlcdb.New(db.DB)
if _, err := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID, Name: name,
}); err == nil {
respondError(c, http.StatusConflict, "CONFLICT", "Environment already exists")
return
}
if err := q.InsertProjectEnvironment(context.Background(), sqlcdb.InsertProjectEnvironmentParams{
Name: name, ProjectID: projectID,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create environment")
return
}
env, _ := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID, Name: name,
})
LogAuditWithRequest(c, "project", projectID.String(), "environment.create", map[string]interface{}{"name": name})
c.JSON(http.StatusCreated, gin.H{"environment": toEnvironmentDTO(env.ID, env.Name, env.ProjectID, env.CreatedAt.Time, env.UpdatedAt.Time, 0)})
}
// handleDeleteEnvironment removes an environment that holds no services.
func handleDeleteEnvironment(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
envID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid environment ID")
return
}
userID, exists := c.Get("user_id")
if !exists {
respondError(c, http.StatusUnauthorized, "UNAUTHORIZED", "User not authenticated")
return
}
var projectID uuid.UUID
if projectID, err = sqlcdb.New(db.DB).GetEnvironmentProjectID(context.Background(), envID); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
return
}
if !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
return
}
q := sqlcdb.New(db.DB)
if n, err := q.CountEnvironmentServices(context.Background(), envID); err == nil && n > 0 {
respondError(c, http.StatusConflict, "CONFLICT", "Environment still contains services — move them first")
return
}
if err := q.DeleteProjectEnvironment(context.Background(), sqlcdb.DeleteProjectEnvironmentParams{
ID: envID, ProjectID: projectID,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete environment")
return
}
LogAuditWithRequest(c, "project", projectID.String(), "environment.delete", map[string]interface{}{"environment_id": envID.String()})
c.JSON(http.StatusOK, gin.H{"message": "Environment deleted"})
}
+324
View File
@@ -0,0 +1,324 @@
package api
// Data plane for the public API gateway: /g/:slug/* proxies to the
// upstream_url of the matching api_services row. Callers authenticate with
// api_keys (X-API-Key or Authorization: Bearer), pass the rpm + monthly
// quota gates, and every request lands in usage_counters /
// metrics_timeseries / incident_events for the /admin/gateway analytics
// surfaces.
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/gateway"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// jarvis: ceiling — a single shared transport; per-request timeouts come
// from api_services.request_timeout_ms on the request context.
var gatewayHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// gatewayRPM is a process-local per-minute counter map. Containr runs a
// single API process, so an in-memory bucket is sufficient — a multi-node
// control plane would need a Redis-backed limiter instead.
var gatewayRPM = struct {
sync.Mutex
buckets map[string]*rpmBucket
}{buckets: map[string]*rpmBucket{}}
type rpmBucket struct {
window time.Time
count int
}
// gatewayRateAllow returns false when the bucket for the current minute has
// already reached limit. A limit <= 0 disables the check. `now` is injected
// so tests can freeze the window.
func gatewayRateAllow(bucketKey string, limit int, now time.Time) bool {
if limit <= 0 {
return true
}
window := now.Truncate(time.Minute)
gatewayRPM.Lock()
defer gatewayRPM.Unlock()
b := gatewayRPM.buckets[bucketKey]
if b == nil || !b.window.Equal(window) {
b = &rpmBucket{window: window}
gatewayRPM.buckets[bucketKey] = b
// Best-effort sweep so the map cannot grow without bound.
for k, old := range gatewayRPM.buckets {
if old.window.Before(window.Add(-time.Minute)) {
delete(gatewayRPM.buckets, k)
}
}
}
b.count++
return b.count <= limit
}
type gatewayService struct {
ID string
UpstreamURL string
RoutePrefix string
UpstreamAuthHeader sql.NullString
UpstreamAuthValue sql.NullString
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
RequestTimeoutMS int
}
type gatewayKey struct {
ID string
Hash string
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
AllowedServiceIDs string
}
// extractGatewayKey reads the caller's API key from X-API-Key first, then
// Authorization: Bearer.
func extractGatewayKey(c *gin.Context) string {
if k := strings.TrimSpace(c.GetHeader("X-API-Key")); k != "" {
return k
}
auth := strings.TrimSpace(c.GetHeader("Authorization"))
if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") {
return strings.TrimSpace(auth[7:])
}
return ""
}
// lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
r, err := sqlcdb.New(db.DB).GetAPServiceBySlug(ctx, slug)
if err != nil {
return nil, err
}
s := gatewayService{
ID: r.ID.String(),
UpstreamURL: r.UpstreamUrl,
RoutePrefix: r.RoutePrefix,
UpstreamAuthHeader: r.UpstreamAuthHeader,
UpstreamAuthValue: r.UpstreamAuthValue,
Enabled: r.Enabled != 0,
RPMLimit: nullInt32To64(r.RpmLimit),
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
RequestTimeoutMS: int(r.RequestTimeoutMs.Int32),
}
return &s, nil
}
func nullInt32To64(v sql.NullInt32) sql.NullInt64 {
return sql.NullInt64{Int64: int64(v.Int32), Valid: v.Valid}
}
// authenticateGatewayKey resolves a presented key by its indexed prefix and
// bcrypt-verifies candidates. Returns nil when nothing matches.
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
prefix := presented
if len(prefix) > 8 {
prefix = prefix[:8]
}
rows, err := sqlcdb.New(db.DB).ListAPKeysByPrefix(ctx, prefix)
if err != nil {
return nil, err
}
for _, r := range rows {
k := gatewayKey{
ID: r.ID.String(),
Hash: r.KeyHash,
Enabled: r.Enabled != 0,
RPMLimit: nullInt32To64(r.RpmLimit),
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
AllowedServiceIDs: r.AllowedServiceIds,
}
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil
}
}
return nil, nil
}
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
// list means the key may call every service.
func keyAllowsService(k *gatewayKey, serviceID string) bool {
var allowed []string
if err := json.Unmarshal([]byte(k.AllowedServiceIDs), &allowed); err != nil || len(allowed) == 0 {
return true
}
for _, id := range allowed {
if id == serviceID {
return true
}
}
return false
}
// monthlyKeyUsage / monthlyServiceUsage sum a counter dimension for the
// current calendar month.
func monthlyKeyUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
keyUUID, _ := uuid.Parse(id)
return sqlcdb.New(db.DB).SumUsageByKey(ctx, sqlcdb.SumUsageByKeyParams{
ApiKeyID: keyUUID, PeriodMonth: period,
})
}
func monthlyServiceUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
svcUUID, _ := uuid.Parse(id)
return sqlcdb.New(db.DB).SumUsageByService(ctx, sqlcdb.SumUsageByServiceParams{
ServiceID: svcUUID, PeriodMonth: period,
})
}
// recordGatewayRequest persists usage, metrics, and failure incidents for a
// proxied call. Runs in the request path but is three small writes.
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
q := sqlcdb.New(db.DB)
keyUUID, _ := uuid.Parse(k.ID)
svcUUID, _ := uuid.Parse(svc.ID)
_ = q.UpsertUsageCounter(ctx, sqlcdb.UpsertUsageCounterParams{
ApiKeyID: keyUUID, ServiceID: svcUUID, PeriodMonth: period,
})
_ = q.TouchAPKeyLastUsed(ctx, keyUUID)
labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID,
"key_id": k.ID,
"status": status,
})
_ = q.InsertMetricPoint(ctx, sql.NullString{String: string(labels), Valid: true})
}
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
var kid uuid.NullUUID
if keyID != nil {
if u, err := uuid.Parse(*keyID); err == nil {
kid = uuid.NullUUID{UUID: u, Valid: true}
}
}
svcUUID, _ := uuid.Parse(svcID)
_ = sqlcdb.New(db.DB).InsertGatewayIncident(ctx, sqlcdb.InsertGatewayIncidentParams{
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: svcUUID != uuid.Nil},
ApiKeyID: kid,
Code: code,
Message: msg,
Severity: severity,
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
})
}
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
// router — outside /api/v1 so route_prefix stays clean.
func handleGatewayProxy(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
slug := c.Param("slug")
svc, err := lookupGatewayService(ctx, db, slug)
if err == sql.ErrNoRows {
respondError(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Gateway service not found")
return
}
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to resolve gateway service")
return
}
if !svc.Enabled {
respondError(c, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Gateway service is disabled")
return
}
presented := extractGatewayKey(c)
if presented == "" {
recordGatewayIncident(ctx, db, svc.ID, nil, "MISSING_API_KEY", "Request without API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "API_KEY_REQUIRED", "An API key is required for this service")
return
}
key, err := authenticateGatewayKey(ctx, db, presented)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to authenticate API key")
return
}
// Disabled and unknown keys collapse to the same 401 — a disabled key
// must not leak its existence.
if key == nil || !key.Enabled {
recordGatewayIncident(ctx, db, svc.ID, nil, "INVALID_API_KEY", "Request with invalid or disabled API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "INVALID_API_KEY", "Invalid API key")
return
}
if !keyAllowsService(key, svc.ID) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "KEY_SCOPE_DENIED", "API key not allowed on this service", "medium", http.StatusForbidden)
respondError(c, http.StatusForbidden, "KEY_SCOPE_DENIED", "API key is not permitted for this service")
return
}
now := time.Now()
if !gatewayRateAllow("key:"+key.ID, int(key.RPMLimit.Int64), now) ||
!gatewayRateAllow("svc:"+svc.ID, int(svc.RPMLimit.Int64), now) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "RATE_LIMITED", "Per-minute rate limit exceeded", "medium", http.StatusTooManyRequests)
respondError(c, http.StatusTooManyRequests, "RATE_LIMITED", "Rate limit exceeded")
return
}
period := now.Format("2006-01")
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
if used, err := monthlyKeyUsage(ctx, db, key.ID, period); err == nil && used >= key.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
return
}
}
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
if used, err := monthlyServiceUsage(ctx, db, svc.ID, period); err == nil && used >= svc.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
return
}
}
target, err := gateway.BuildTargetURL(svc.UpstreamURL, svc.RoutePrefix, c.Request.URL.Path, c.Request.URL.RawQuery)
if err != nil {
respondError(c, http.StatusBadGateway, "UPSTREAM_URL_INVALID", "Gateway upstream is misconfigured")
return
}
headers := map[string]string{}
if svc.UpstreamAuthHeader.Valid && svc.UpstreamAuthHeader.String != "" {
headers[svc.UpstreamAuthHeader.String] = svc.UpstreamAuthValue.String
}
timeout := time.Duration(svc.RequestTimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 8 * time.Second
}
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
status, _, err := gateway.ProxyRequest(gatewayHTTPClient, c.Writer, c.Request.WithContext(reqCtx), target, headers)
if err != nil {
recordGatewayIncident(reqCtx, db, svc.ID, &key.ID, "UPSTREAM_ERROR", err.Error(), "high", http.StatusBadGateway)
recordGatewayRequest(ctx, db, svc, key, http.StatusBadGateway, period)
respondError(c, http.StatusBadGateway, "UPSTREAM_ERROR", "Upstream request failed")
return
}
recordGatewayRequest(ctx, db, svc, key, status, period)
if status >= 500 {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "UPSTREAM_5XX", "Upstream returned "+http.StatusText(status), "high", status)
}
}
@@ -0,0 +1,81 @@
package api
import (
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestGatewayRateAllow(t *testing.T) {
now := time.Date(2026, 10, 15, 12, 0, 30, 0, time.UTC)
for i := 0; i < 3; i++ {
if !gatewayRateAllow("test:a", 3, now) {
t.Fatalf("call %d within limit rejected", i+1)
}
}
if gatewayRateAllow("test:a", 3, now) {
t.Fatal("call beyond limit allowed")
}
if !gatewayRateAllow("test:a", 3, now.Add(time.Minute)) {
t.Fatal("new minute window still rejected")
}
if !gatewayRateAllow("test:unlimited", 0, now) {
t.Fatal("non-positive limit should disable the check")
}
}
func TestExtractGatewayKey(t *testing.T) {
mkctx := func(headers map[string]string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/g/x", nil)
for k, v := range headers {
c.Request.Header.Set(k, v)
}
return c
}
if got := extractGatewayKey(mkctx(map[string]string{"X-API-Key": "ap_abc"})); got != "ap_abc" {
t.Fatalf("X-API-Key not extracted: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{"Authorization": "Bearer ap_def"})); got != "ap_def" {
t.Fatalf("Bearer not extracted: %q", got)
}
// X-API-Key wins when both are present.
if got := extractGatewayKey(mkctx(map[string]string{
"X-API-Key": "ap_first",
"Authorization": "Bearer ap_second",
})); got != "ap_first" {
t.Fatalf("X-API-Key should take precedence: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{})); got != "" {
t.Fatalf("expected empty key, got %q", got)
}
}
func TestKeyAllowsService(t *testing.T) {
svcID := "11111111-2222-3333-4444-555555555555"
other := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
cases := []struct {
name string
allowed string
want bool
}{
{"empty list permits all", "[]", true},
{"blank permits all", "", true},
{"invalid json permits all", "not-json", true},
{"listed service allowed", `["` + svcID + `"]`, true},
{"unlisted service denied", `["` + other + `"]`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
k := &gatewayKey{AllowedServiceIDs: tc.allowed}
if got := keyAllowsService(k, svcID); got != tc.want {
t.Fatalf("keyAllowsService(%q) = %v, want %v", tc.allowed, got, tc.want)
}
})
}
}
+197 -221
View File
@@ -2,6 +2,8 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@@ -92,41 +94,73 @@ type CreateWebhookRequest struct {
Branch string `json:"branch"`
}
func gitRepoFrom(r sqlcdb.ListGitRepositoriesRow) GitRepository {
return GitRepository{
ID: r.ID.String(),
ProviderID: r.ProviderID.String(),
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
CloneURL: r.CloneUrl,
WebhookURL: r.WebhookUrl,
DefaultBranch: r.DefaultBranch.String,
IsPrivate: r.IsPrivate.Bool,
UserID: r.UserID.String(),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
func gitRepoFromFullNameRow(r sqlcdb.GetGitRepositoryByFullNameRow) GitRepository {
return GitRepository{
ID: r.ID.String(),
ProviderID: r.ProviderID.String(),
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
CloneURL: r.CloneUrl,
WebhookURL: r.WebhookUrl,
DefaultBranch: r.DefaultBranch.String,
IsPrivate: r.IsPrivate.Bool,
UserID: r.UserID.String(),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
func gitProviderFrom(p sqlcdb.ListGitProvidersRow) GitProvider {
return GitProvider{
ID: p.ID.String(),
Name: p.Name,
DisplayName: p.DisplayName,
APIUrl: p.ApiUrl,
WebhookUrl: p.WebhookUrl,
UserID: p.UserID.String(),
CreatedAt: p.CreatedAt.Time,
UpdatedAt: p.UpdatedAt.Time,
}
}
func handleGetGitProviders(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
rows, err := ctx.db.Query(`
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC
`, ctx.userID)
userID, err := uuid.Parse(ctx.userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
rows, err := sqlcdb.New(ctx.db.DB).ListGitProviders(context.Background(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
providers := make([]GitProvider, 0)
for rows.Next() {
var provider GitProvider
if err := rows.Scan(
&provider.ID,
&provider.Name,
&provider.DisplayName,
&provider.APIUrl,
&provider.WebhookUrl,
&provider.UserID,
&provider.CreatedAt,
&provider.UpdatedAt,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providers = append(providers, provider)
providers := make([]GitProvider, 0, len(rows))
for _, p := range rows {
providers = append(providers, gitProviderFrom(p))
}
c.JSON(http.StatusOK, gin.H{"providers": providers})
@@ -173,21 +207,22 @@ func handleConnectGitHubApp(c *gin.Context) {
UserID: ctx.userID,
}
if err := ctx.db.QueryRow(`
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
ON CONFLICT (name, user_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
api_url = EXCLUDED.api_url,
webhook_url = EXCLUDED.webhook_url,
access_token = EXCLUDED.access_token,
updated_at = NOW()
RETURNING id, created_at, updated_at
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID).Scan(&provider.ID, &provider.CreatedAt, &provider.UpdatedAt); err != nil {
prow, err := sqlcdb.New(ctx.db.DB).UpsertGitHubAppProvider(context.Background(), sqlcdb.UpsertGitHubAppProviderParams{
ID: uuid.MustParse(provider.ID),
Name: provider.Name,
DisplayName: provider.DisplayName,
ApiUrl: provider.APIUrl,
WebhookUrl: provider.WebhookUrl,
AccessToken: provider.AccessToken,
UserID: uuid.MustParse(provider.UserID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect GitHub App installation"})
return
}
provider.ID = prow.ID.String()
provider.CreatedAt = prow.CreatedAt.Time
provider.UpdatedAt = prow.UpdatedAt.Time
provider.AccessToken = ""
c.JSON(http.StatusOK, gin.H{
@@ -259,10 +294,16 @@ func handleCreateGitProvider(c *gin.Context) {
return
}
if _, err := ctx.db.Exec(`
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID); err != nil {
q := sqlcdb.New(ctx.db.DB)
if err := q.InsertGitProvider(context.Background(), sqlcdb.InsertGitProviderParams{
ID: uuid.MustParse(provider.ID),
Name: provider.Name,
DisplayName: provider.DisplayName,
ApiUrl: provider.APIUrl,
WebhookUrl: provider.WebhookUrl,
AccessToken: provider.AccessToken,
UserID: uuid.MustParse(provider.UserID),
}); err != nil {
if isUniqueConstraintError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "Provider already connected for this account"})
return
@@ -271,13 +312,12 @@ func handleCreateGitProvider(c *gin.Context) {
return
}
if err := ctx.db.QueryRow(`
SELECT created_at, updated_at
FROM git_providers
WHERE id = $1
`, provider.ID).Scan(&provider.CreatedAt, &provider.UpdatedAt); err != nil {
if ts, err := q.GetGitProviderTimestamps(context.Background(), uuid.MustParse(provider.ID)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read created provider"})
return
} else {
provider.CreatedAt = ts.CreatedAt.Time
provider.UpdatedAt = ts.UpdatedAt.Time
}
provider.AccessToken = ""
@@ -296,12 +336,11 @@ func handleGetGitRepositories(c *gin.Context) {
return
}
var providerName, providerDisplayName, providerAccessToken, providerAPIURL string
err := ctx.db.QueryRow(`
SELECT name, display_name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, providerID, ctx.userID).Scan(&providerName, &providerDisplayName, &providerAccessToken, &providerAPIURL)
q := sqlcdb.New(ctx.db.DB)
provRow, err := q.GetGitProviderForRepos(context.Background(), sqlcdb.GetGitProviderForReposParams{
ID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
@@ -310,6 +349,10 @@ func handleGetGitRepositories(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providerName := provRow.Name
providerDisplayName := provRow.DisplayName
providerAccessToken := provRow.AccessToken
providerAPIURL := provRow.ApiUrl
if c.DefaultQuery("sync", "1") != "0" {
if syncErr := syncProviderRepositories(ctx.db, providerID, ctx.userID, providerName, providerAPIURL, providerAccessToken); syncErr != nil {
@@ -324,53 +367,30 @@ func handleGetGitRepositories(c *gin.Context) {
}
offset := (page - 1) * limit
search := strings.TrimSpace(c.Query("search"))
searchNullable := sql.NullString{String: search, Valid: search != ""}
rows, err := ctx.db.Query(`
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''),
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
ORDER BY updated_at DESC
LIMIT $4 OFFSET $5
`, providerID, ctx.userID, searchNullable, limit, offset)
repoRows, err := q.ListGitRepositories(context.Background(), sqlcdb.ListGitRepositoriesParams{
ProviderID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
Column3: search,
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
repositories := make([]GitRepository, 0)
for rows.Next() {
var repo GitRepository
if err := rows.Scan(
&repo.ID,
&repo.ProviderID,
&repo.Name,
&repo.FullName,
&repo.Description,
&repo.CloneURL,
&repo.WebhookURL,
&repo.DefaultBranch,
&repo.IsPrivate,
&repo.UserID,
&repo.CreatedAt,
&repo.UpdatedAt,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories = append(repositories, repo)
repositories := make([]GitRepository, 0, len(repoRows))
for _, r := range repoRows {
repositories = append(repositories, gitRepoFrom(r))
}
var total int
if err := ctx.db.QueryRow(`
SELECT COUNT(*)
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
`, providerID, ctx.userID, searchNullable).Scan(&total); err != nil {
total, err := q.CountGitRepositories(context.Background(), sqlcdb.CountGitRepositoriesParams{
ProviderID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
Column3: search,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
@@ -408,12 +428,11 @@ func handleConnectGitRepository(c *gin.Context) {
return
}
var providerName string
err := ctx.db.QueryRow(`
SELECT name
FROM git_providers
WHERE id = $1 AND user_id = $2
`, providerID, ctx.userID).Scan(&providerName)
q := sqlcdb.New(ctx.db.DB)
providerName, err := q.GetGitProviderName(context.Background(), sqlcdb.GetGitProviderNameParams{
ID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
@@ -429,31 +448,16 @@ func handleConnectGitRepository(c *gin.Context) {
return
}
var existing GitRepository
err = ctx.db.QueryRow(`
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''),
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3
`, providerID, fullName, ctx.userID).Scan(
&existing.ID,
&existing.ProviderID,
&existing.Name,
&existing.FullName,
&existing.Description,
&existing.CloneURL,
&existing.WebhookURL,
&existing.DefaultBranch,
&existing.IsPrivate,
&existing.UserID,
&existing.CreatedAt,
&existing.UpdatedAt,
)
existingRow, err := q.GetGitRepositoryByFullName(context.Background(), sqlcdb.GetGitRepositoryByFullNameParams{
ProviderID: uuid.MustParse(providerID),
FullName: fullName,
UserID: uuid.MustParse(ctx.userID),
})
switch {
case err == nil:
c.JSON(http.StatusOK, gin.H{
"message": "Repository already connected",
"repository": existing,
"repository": gitRepoFromFullNameRow(existingRow),
})
return
case !errors.Is(err, sql.ErrNoRows):
@@ -473,13 +477,18 @@ func handleConnectGitRepository(c *gin.Context) {
UserID: ctx.userID,
}
if err := ctx.db.QueryRow(`
INSERT INTO git_repositories (
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
RETURNING created_at, updated_at
`, repo.ID, repo.ProviderID, repo.Name, repo.FullName, repo.Description, repo.CloneURL, repo.DefaultBranch, repo.IsPrivate, repo.UserID).Scan(&repo.CreatedAt, &repo.UpdatedAt); err != nil {
ins, err := q.InsertGitRepository(context.Background(), sqlcdb.InsertGitRepositoryParams{
ID: uuid.MustParse(repo.ID),
ProviderID: uuid.MustParse(repo.ProviderID),
Name: repo.Name,
FullName: repo.FullName,
Description: sql.NullString{String: repo.Description, Valid: true},
CloneUrl: repo.CloneURL,
DefaultBranch: sql.NullString{String: repo.DefaultBranch, Valid: true},
IsPrivate: sql.NullBool{Bool: repo.IsPrivate, Valid: true},
UserID: uuid.MustParse(repo.UserID),
})
if err != nil {
if isUniqueConstraintError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "Repository is already connected"})
return
@@ -487,6 +496,8 @@ func handleConnectGitRepository(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"})
return
}
repo.CreatedAt = ins.CreatedAt.Time
repo.UpdatedAt = ins.UpdatedAt.Time
c.JSON(http.StatusCreated, gin.H{
"message": "Repository connected successfully",
@@ -516,12 +527,10 @@ func handleCreateWebhook(c *gin.Context) {
return
}
var providerID string
err := ctx.db.QueryRow(`
SELECT provider_id
FROM git_repositories
WHERE id = $1 AND user_id = $2
`, repoID, ctx.userID).Scan(&providerID)
providerUUID, err := sqlcdb.New(ctx.db.DB).GetGitRepositoryProviderID(context.Background(), sqlcdb.GetGitRepositoryProviderIDParams{
ID: uuid.MustParse(repoID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
@@ -530,6 +539,7 @@ func handleCreateWebhook(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providerID := providerUUID.String()
eventsJSON, err := json.Marshal(req.Events)
if err != nil {
@@ -552,23 +562,22 @@ func handleCreateWebhook(c *gin.Context) {
Active: true,
}
if err := ctx.db.QueryRow(`
INSERT INTO git_webhooks (
id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
ON CONFLICT (repo_id, provider_id)
DO UPDATE SET
events = EXCLUDED.events,
webhook_secret = EXCLUDED.webhook_secret,
active = TRUE,
branch_filter = EXCLUDED.branch_filter,
updated_at = NOW()
RETURNING id, active, created_at, updated_at
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, strings.TrimSpace(req.Branch)).Scan(&webhook.ID, &webhook.Active, &webhook.CreatedAt, &webhook.UpdatedAt); err != nil {
wrow, err := sqlcdb.New(ctx.db.DB).UpsertGitWebhook(context.Background(), sqlcdb.UpsertGitWebhookParams{
ID: uuid.MustParse(webhook.ID),
RepoID: uuid.MustParse(webhook.RepoID),
ProviderID: uuid.MustParse(webhook.ProviderID),
Events: webhook.Events,
WebhookSecret: webhook.Secret,
BranchFilter: sql.NullString{String: strings.TrimSpace(req.Branch), Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure webhook"})
return
}
webhook.ID = wrow.ID.String()
webhook.Active = wrow.Active.Bool
webhook.CreatedAt = wrow.CreatedAt.Time
webhook.UpdatedAt = wrow.UpdatedAt.Time
c.JSON(http.StatusCreated, gin.H{
"message": "Webhook configured successfully",
@@ -598,69 +607,39 @@ func handleGetConnectedRepositories(c *gin.Context) {
}
offset := (page - 1) * limit
rows, err := ctx.db.Query(`
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, ''), r.clone_url,
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
p.name as provider_name, p.display_name
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3
`, ctx.userID, limit, offset)
q := sqlcdb.New(ctx.db.DB)
repoRows, err := q.ListConnectedRepositories(context.Background(), sqlcdb.ListConnectedRepositoriesParams{
UserID: uuid.MustParse(ctx.userID),
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
repositories := make([]map[string]interface{}, 0)
for rows.Next() {
var repo GitRepository
var providerName, providerDisplayName string
if err := rows.Scan(
&repo.ID,
&repo.ProviderID,
&repo.Name,
&repo.FullName,
&repo.Description,
&repo.CloneURL,
&repo.DefaultBranch,
&repo.IsPrivate,
&repo.UserID,
&repo.CreatedAt,
&repo.UpdatedAt,
&providerName,
&providerDisplayName,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories := make([]map[string]interface{}, 0, len(repoRows))
for _, r := range repoRows {
repositories = append(repositories, map[string]interface{}{
"id": repo.ID,
"provider_id": repo.ProviderID,
"name": repo.Name,
"full_name": repo.FullName,
"description": repo.Description,
"clone_url": repo.CloneURL,
"default_branch": repo.DefaultBranch,
"is_private": repo.IsPrivate,
"created_at": repo.CreatedAt,
"updated_at": repo.UpdatedAt,
"id": r.ID.String(),
"provider_id": r.ProviderID.String(),
"name": r.Name,
"full_name": r.FullName,
"description": r.Description,
"clone_url": r.CloneUrl,
"default_branch": r.DefaultBranch.String,
"is_private": r.IsPrivate.Bool,
"created_at": r.CreatedAt.Time,
"updated_at": r.UpdatedAt.Time,
"provider": map[string]string{
"name": providerName,
"display_name": providerDisplayName,
"name": r.ProviderName,
"display_name": r.DisplayName,
},
})
}
var total int
if err := ctx.db.QueryRow(`
SELECT COUNT(*)
FROM git_repositories
WHERE user_id = $1
`, ctx.userID).Scan(&total); err != nil {
total, err := q.CountUserRepositories(context.Background(), uuid.MustParse(ctx.userID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
@@ -705,20 +684,17 @@ func syncProviderRepositories(db *database.DB, providerID, userID, providerName,
defaultBranch = "main"
}
_, err := db.Exec(`
INSERT INTO git_repositories (
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
ON CONFLICT (provider_id, full_name)
DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
clone_url = EXCLUDED.clone_url,
default_branch = EXCLUDED.default_branch,
is_private = EXCLUDED.is_private,
updated_at = NOW()
`, uuid.NewString(), providerID, repository.Name, repository.FullName, repository.Description, cloneURL, defaultBranch, repository.IsPrivate, userID)
err := sqlcdb.New(db.DB).UpsertGitRepository(context.Background(), sqlcdb.UpsertGitRepositoryParams{
ID: uuid.New(),
ProviderID: uuid.MustParse(providerID),
Name: repository.Name,
FullName: repository.FullName,
Description: sql.NullString{String: repository.Description, Valid: true},
CloneUrl: cloneURL,
DefaultBranch: sql.NullString{String: defaultBranch, Valid: true},
IsPrivate: sql.NullBool{Bool: repository.IsPrivate, Valid: true},
UserID: uuid.MustParse(userID),
})
if err != nil {
return err
}
@@ -1323,16 +1299,15 @@ func handleDeleteGitProvider(c *gin.Context) {
return
}
result, err := ctx.db.Exec(
"DELETE FROM git_providers WHERE id = $1 AND user_id = $2",
providerID, ctx.userID,
)
affected, err := sqlcdb.New(ctx.db.DB).DeleteGitProvider(context.Background(), sqlcdb.DeleteGitProviderParams{
ID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
return
@@ -1362,12 +1337,10 @@ func handleGetGitRepositoryBranches(c *gin.Context) {
return
}
var providerName, providerAPIURL, providerAccessToken string
err := ctx.db.QueryRow(`
SELECT name, api_url, access_token
FROM git_providers
WHERE id = $1 AND user_id = $2
`, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken)
pf, err := sqlcdb.New(ctx.db.DB).GetGitProviderForFetch(context.Background(), sqlcdb.GetGitProviderForFetchParams{
ID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
@@ -1376,6 +1349,9 @@ func handleGetGitRepositoryBranches(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providerName := pf.Name
providerAPIURL := pf.ApiUrl
providerAccessToken := pf.AccessToken
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
if err != nil {
+69 -64
View File
@@ -5,6 +5,7 @@ import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
@@ -14,6 +15,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
@@ -38,22 +40,15 @@ func handleGitWebhookPush(c *gin.Context) {
return
}
var (
secret string
repoID string
providerID string
branchFilter string
active bool
)
err := db.QueryRow(`
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active
FROM git_webhooks WHERE id = $1`, webhookID).
Scan(&secret, &repoID, &providerID, &branchFilter, &active)
q := sqlcdb.New(db.DB)
hook, err := q.GetGitWebhookForPush(context.Background(), uuid.MustParse(webhookID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
return
}
if !active {
secret := hook.WebhookSecret
branchFilter := hook.BranchFilter
if !hook.Active.Bool {
c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"})
return
}
@@ -64,8 +59,7 @@ func handleGitWebhookPush(c *gin.Context) {
return
}
var providerName string
_ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName)
providerName, _ := q.GetGitProviderNameByID(context.Background(), hook.ProviderID)
if !verifyGitWebhookSignature(providerName, secret, c.Request, body) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
@@ -118,14 +112,12 @@ func handleGitWebhookPush(c *gin.Context) {
commit = payload.After
}
var cloneURL, fullName, repoUserID string
err = db.QueryRow(
`SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID,
).Scan(&cloneURL, &fullName, &repoUserID)
repo, err := q.GetGitRepoForPush(context.Background(), hook.RepoID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return
}
cloneURL, fullName, repoUserID := repo.CloneUrl, repo.FullName, repo.UserID.String()
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
@@ -147,34 +139,37 @@ func handleGitWebhookPush(c *gin.Context) {
// the pushed repo+branch. Shared by the per-repo webhook receiver and the
// GitHub App webhook endpoint.
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
// Sync-style events may carry no branch — match services on repo only.
branchClause := "s.git_branch = $1"
if branch == "" {
branchClause = "TRUE"
}
rows, err := db.Query(fmt.Sprintf(`
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at
FROM services s
WHERE %s AND (s.git_repo = $2 OR s.git_repo = $3)`, branchClause),
branch, cloneURL, fullName)
q := sqlcdb.New(db.DB)
ctx := context.Background()
// Sync-style events may carry no branch — empty branch matches all.
rows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
GitBranch: sql.NullString{String: branch, Valid: true},
GitRepo: sql.NullString{String: cloneURL, Valid: true},
GitRepo_2: sql.NullString{String: fullName, Valid: true},
})
if err != nil {
return 0, err
}
defer rows.Close()
var services []Service
for rows.Next() {
var s Service
if err := rows.Scan(
&s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command,
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
&s.CreatedAt, &s.UpdatedAt,
); err != nil {
return 0, err
}
services = append(services, s)
for _, row := range rows {
services = append(services, Service{
ID: row.ID,
ProjectID: row.ProjectID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Image: row.Image,
Command: row.Command,
Environment: row.Environment,
GitRepo: row.GitRepo,
GitBranch: row.GitBranch,
BuildPath: row.BuildPath,
CPU: row.Cpu,
Memory: row.Memory,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
})
}
enqueued := 0
@@ -192,27 +187,34 @@ func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.
CreatedAt: now,
UpdatedAt: now,
}
if _, err := db.Exec(
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
); err != nil {
if err := q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: d.ID,
ServiceID: d.ServiceID,
Version: fmt.Sprintf("v%d", now.Unix()),
CommitHash: sql.NullString{String: ptrStr(d.CommitHash), Valid: d.CommitHash != nil},
Status: sql.NullString{String: d.Status, Valid: true},
ImageName: sql.NullString{String: d.ImageName, Valid: true},
ImageTag: sql.NullString{String: d.ImageTag, Valid: true},
CreatedAt: sql.NullTime{Time: d.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: d.UpdatedAt, Valid: true},
}); err != nil {
continue
}
if engine == nil {
failedAt := time.Now()
failure := "Deployment engine unavailable. Docker may not be configured on this server."
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
failure, failedAt, d.ID,
)
failedAt := time.Now()
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: failure, Valid: true},
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: d.ID,
})
continue
}
_, _ = db.Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "building", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: service.ID,
})
// Webhook pushes default to a clean build — stale layers are the
// classic silent-rollback failure (dflow convention).
branchForBuild := branch
@@ -226,14 +228,17 @@ func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
},
}); pos > 0 {
_, _ = db.Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
now := sql.NullTime{Time: time.Now(), Valid: true}
_ = q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: now,
ID: d.ID,
})
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: now,
ID: service.ID,
})
}
enqueued++
}
+203 -1
View File
@@ -1,6 +1,16 @@
package api
import "testing"
import (
"context"
"database/sql"
"strings"
"testing"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
)
func TestNormalizeRepositoryFullName(t *testing.T) {
cases := []struct {
@@ -83,3 +93,195 @@ func TestDeriveCloneURL(t *testing.T) {
})
}
}
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL). Covers the generated git provider/repo/webhook queries plus
// the push-dispatch service matcher (empty branch must match all branches).
func TestGitLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
providerID := uuid.New()
repoID := uuid.New()
webhookID := uuid.New()
svcMain := uuid.New()
svcDev := uuid.New()
suffix := strings.ReplaceAll(userID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "git-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "git-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
// Provider lifecycle.
must(q.InsertGitProvider(ctx, sqlcdb.InsertGitProviderParams{
ID: providerID, Name: "github", DisplayName: "GitHub",
ApiUrl: "https://api.github.com", WebhookUrl: "https://hooks.test",
AccessToken: "tok", UserID: userID,
}))
providers, err := q.ListGitProviders(ctx, userID)
if err != nil || len(providers) == 0 {
t.Fatalf("providers: %v %v", err, providers)
}
name, err := q.GetGitProviderNameByID(ctx, providerID)
if err != nil || name != "github" {
t.Fatalf("provider name: %v %q", err, name)
}
fetch, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{ID: providerID, UserID: userID})
if err != nil || fetch.AccessToken != "tok" || fetch.ApiUrl != "https://api.github.com" {
t.Fatalf("fetch: %v %+v", err, fetch)
}
// Repository insert + search.
cloneURL := "https://github.com/acme/svc-" + suffix + ".git"
fullName := "acme/svc-" + suffix
_, err = q.InsertGitRepository(ctx, sqlcdb.InsertGitRepositoryParams{
ID: repoID, ProviderID: providerID, Name: "svc-" + suffix, FullName: fullName,
Description: sql.NullString{String: "", Valid: true},
CloneUrl: cloneURL,
DefaultBranch: sql.NullString{String: "main", Valid: true},
IsPrivate: sql.NullBool{Bool: false, Valid: true},
UserID: userID,
})
must(err)
byName, err := q.GetGitRepositoryByFullName(ctx, sqlcdb.GetGitRepositoryByFullNameParams{
ProviderID: providerID, FullName: fullName, UserID: userID,
})
if err != nil || byName.ID != repoID || byName.CloneUrl != cloneURL {
t.Fatalf("repo by full name: %v %+v", err, byName)
}
all, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
ProviderID: providerID, UserID: userID, Column3: "", Limit: 50, Offset: 0,
})
if err != nil || len(all) == 0 {
t.Fatalf("repo list (empty search): %v %v", err, all)
}
hit, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
ProviderID: providerID, UserID: userID, Column3: "svc-" + suffix, Limit: 50, Offset: 0,
})
if err != nil || len(hit) != 1 || hit[0].ID != repoID {
t.Fatalf("repo list (matching search): %v %v", err, hit)
}
miss, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
ProviderID: providerID, UserID: userID, Column3: "zzz-no-match", Limit: 50, Offset: 0,
})
if err != nil || len(miss) != 0 {
t.Fatalf("repo list (non-matching search): %v %v", err, miss)
}
count, err := q.CountGitRepositories(ctx, sqlcdb.CountGitRepositoriesParams{
ProviderID: providerID, UserID: userID, Column3: "",
})
if err != nil || count < 1 {
t.Fatalf("repo count: %v %d", err, count)
}
// Upsert on (provider_id, full_name) conflict — updates rather than errors.
must(q.UpsertGitRepository(ctx, sqlcdb.UpsertGitRepositoryParams{
ID: uuid.New(), ProviderID: providerID, Name: "renamed-" + suffix, FullName: fullName,
Description: sql.NullString{String: "d", Valid: true},
CloneUrl: cloneURL,
DefaultBranch: sql.NullString{String: "main", Valid: true},
IsPrivate: sql.NullBool{Bool: true, Valid: true},
UserID: userID,
}))
byName, err = q.GetGitRepositoryByFullName(ctx, sqlcdb.GetGitRepositoryByFullNameParams{
ProviderID: providerID, FullName: fullName, UserID: userID,
})
if err != nil || byName.Name != "renamed-"+suffix {
t.Fatalf("repo after upsert: %v %+v", err, byName)
}
// Connected-repos join + webhook lifecycle.
connected, err := q.ListConnectedRepositories(ctx, sqlcdb.ListConnectedRepositoriesParams{
UserID: userID, Limit: 50, Offset: 0,
})
if err != nil || len(connected) == 0 {
t.Fatalf("connected repos: %v %v", err, connected)
}
wh, err := q.UpsertGitWebhook(ctx, sqlcdb.UpsertGitWebhookParams{
ID: webhookID, RepoID: repoID, ProviderID: providerID,
Events: "push", WebhookSecret: "sec-" + suffix,
BranchFilter: sql.NullString{String: "", Valid: true},
})
must(err)
if !wh.Active.Bool {
t.Fatalf("webhook inactive after upsert: %+v", wh)
}
push, err := q.GetGitWebhookForPush(ctx, webhookID)
if err != nil || push.WebhookSecret != "sec-"+suffix || push.ProviderID != providerID {
t.Fatalf("webhook for push: %v %+v", err, push)
}
pushRepo, err := q.GetGitRepoForPush(ctx, repoID)
if err != nil || pushRepo.CloneUrl != cloneURL || pushRepo.FullName != fullName || pushRepo.UserID != userID {
t.Fatalf("repo for push: %v %+v", err, pushRepo)
}
// Push-dispatch service matcher: branch filter and empty-branch match-all.
for id, branch := range map[uuid.UUID]string{svcMain: "main", svcDev: "dev"} {
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, git_repo, git_branch)
VALUES ($1, $2, $3, $4, 'web', 'git', $5, $6)`,
id, "svc-"+suffix+"-"+branch, projectID, envID, cloneURL, branch)
must(err)
}
servicesFor := func(branch string) []sqlcdb.ListServicesForPushRow {
rows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
GitBranch: sql.NullString{String: branch, Valid: true},
GitRepo: sql.NullString{String: cloneURL, Valid: true},
GitRepo_2: sql.NullString{String: fullName, Valid: true},
})
if err != nil {
t.Fatalf("services for push %q: %v", branch, err)
}
return rows
}
mainRows := servicesFor("main")
if len(mainRows) != 1 || mainRows[0].ID != svcMain || mainRows[0].GitBranch != "main" {
t.Fatalf("main branch dispatch: %+v", mainRows)
}
devRows := servicesFor("dev")
if len(devRows) != 1 || devRows[0].ID != svcDev {
t.Fatalf("dev branch dispatch: %+v", devRows)
}
allRows := servicesFor("")
if len(allRows) != 2 {
t.Fatalf("empty-branch dispatch should match all: %+v", allRows)
}
// Repo may also be referenced by its full_name.
fullRows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
GitBranch: sql.NullString{String: "main", Valid: true},
GitRepo: sql.NullString{String: "[email protected]:other/none.git", Valid: true},
GitRepo_2: sql.NullString{String: cloneURL, Valid: true},
})
if err != nil || len(fullRows) != 1 {
t.Fatalf("full-name repo match: %v %+v", err, fullRows)
}
// Provider delete is owner-scoped.
other, err := q.DeleteGitProvider(ctx, sqlcdb.DeleteGitProviderParams{ID: providerID, UserID: uuid.New()})
if err != nil || other != 0 {
t.Fatalf("foreign delete affected %d rows: %v", other, err)
}
deleted, err := q.DeleteGitProvider(ctx, sqlcdb.DeleteGitProviderParams{ID: providerID, UserID: userID})
if err != nil || deleted != 1 {
t.Fatalf("owner delete affected %d rows: %v", deleted, err)
}
}
+27 -18
View File
@@ -1,12 +1,16 @@
package api
import (
"context"
"fmt"
"strconv"
"strings"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/source"
"github.com/google/uuid"
)
// resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or
@@ -22,26 +26,31 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
}
spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath}
q := sqlcdb.New(db.DB)
ctx := context.Background()
var providerID string
ownerUUID, _ := uuid.Parse(ownerID)
var providerID uuid.UUID
if !strings.Contains(cloneURL, "://") {
// Bare owner/repo — the connected repository row knows the host.
err = db.QueryRow(
`SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&cloneURL, &providerID)
row, err := q.GetGitRepoCloneByFullName(ctx, sqlcdb.GetGitRepoCloneByFullNameParams{
FullName: cloneURL,
UserID: ownerUUID,
})
if err != nil {
return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw)
}
cloneURL = row.CloneUrl
providerID = row.ProviderID
} else {
// URL form — match a connected repo for its provider when possible;
// an unmatched public URL still clones, just without credentials.
_ = db.QueryRow(
`SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&providerID)
if pid, err := q.GetGitRepoProviderByCloneURL(ctx, sqlcdb.GetGitRepoProviderByCloneURLParams{
CloneUrl: cloneURL,
UserID: ownerUUID,
}); err == nil {
providerID = pid
}
}
if err := source.ValidateRemoteURL(cloneURL); err != nil {
@@ -49,17 +58,17 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
}
spec.CloneURL = cloneURL
if providerID == "" {
if providerID == uuid.Nil {
return spec, nil
}
var name, apiURL, token string
if err := db.QueryRow(
`SELECT name, api_url, access_token FROM git_providers
WHERE id = $1 AND user_id = $2`,
providerID, ownerID,
).Scan(&name, &apiURL, &token); err != nil {
prov, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{
ID: providerID,
UserID: ownerUUID,
})
if err != nil {
return spec, nil // provider row gone — anonymous clone still works for public repos
}
name, apiURL, token := prov.Name, prov.ApiUrl, prov.AccessToken
switch strings.ToLower(name) {
case "github_app":
+4 -4
View File
@@ -15,6 +15,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"github.com/gin-gonic/gin"
@@ -88,8 +89,8 @@ func handleAdminGitHubAppManifest(c *gin.Context) {
callbackURL := base + "/git/github-app/callback"
manifest, err := json.Marshal(map[string]interface{}{
"name": name,
"url": base,
"name": name,
"url": base,
"hook_attributes": map[string]interface{}{
"url": base + "/api/git/github-app/webhook",
"active": true,
@@ -206,9 +207,8 @@ func handleAdminGitHubAppConvert(c *gin.Context) {
// GET /admin/git/github-app — provisioning status for the settings UI.
func handleAdminGetGitHubApp(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
var stored string
source := "none"
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
if v, err := sqlcdb.New(db.DB).GetAppSetting(c.Request.Context(), settingGitHubAppID); err == nil && strings.TrimSpace(v) != "" {
source = "settings"
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
source = "env"
+15 -9
View File
@@ -193,8 +193,8 @@ func handleAcceptInvite(c *gin.Context) {
return
}
var existing int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return
}
@@ -214,17 +214,23 @@ func handleAcceptInvite(c *gin.Context) {
return
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{
Email: req.Email,
PasswordHash: string(hashedPassword),
Name: strings.TrimSpace(req.Name),
})
if err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return
}
user := User{
ID: row.ID.String(),
Email: row.Email,
Name: row.Name,
AvatarURL: row.AvatarUrl,
IsAdmin: row.IsAdmin,
CreatedAt: row.CreatedAt.Time.String(),
}
userUUID, _ := uuid.Parse(user.ID)
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
+3 -7
View File
@@ -9,6 +9,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
containertypes "github.com/docker/docker/api/types/container"
@@ -60,17 +61,12 @@ func handleGetServiceMetrics(c *gin.Context) {
return
}
var projectID uuid.UUID
var status string
err = db.QueryRow(
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&projectID, &status)
svc, err := sqlcdb.New(db.DB).GetServiceProjectAndStatus(context.Background(), serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
projectID := svc.ProjectID
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
+3 -4
View File
@@ -315,10 +315,9 @@ func (r *agentNodeRunner) replicaPayload(spec deployment.RuntimeSpec, agentID st
// pullCredentials resolves the project owner's registry auth for the image
// host so private images pull on remote nodes. Empty map → anonymous pull.
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
var ownerID string
if err := r.db.QueryRow(
`SELECT owner_id FROM projects WHERE id = $1`, projectID,
).Scan(&ownerID); err != nil {
pid, _ := uuid.Parse(projectID)
ownerID, err := sqlcdb.New(r.db.DB).GetProjectOwnerText(context.Background(), pid)
if err != nil {
return map[string]interface{}{}
}
auth := registryAuthFor(r.db, ownerID, imageRef)
@@ -0,0 +1,225 @@
package api
// Push notification channels — self-hosted ntfy and Gotify endpoints users
// attach to their account. insertUserNotification fans out best-effort.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// jarvis: ceiling — one shared client; no retry/backoff, delivery is
// best-effort and failures are silent by design (notifications must never
// break the producing flow).
var pushHTTPClient = &http.Client{Timeout: 5 * time.Second}
type notificationChannelDTO struct {
ID string `json:"id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
func mapChannel(row sqlcdb.NotificationChannel) notificationChannelDTO {
return notificationChannelDTO{
ID: row.ID.String(),
Kind: row.Kind,
Endpoint: row.Endpoint,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt.Time.Format(time.RFC3339),
}
}
func handleListNotificationChannels(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list channels")
return
}
out := make([]notificationChannelDTO, 0, len(rows))
for _, r := range rows {
out = append(out, mapChannel(r))
}
c.JSON(http.StatusOK, gin.H{"channels": out})
}
func handleCreateNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
var req struct {
Kind string `json:"kind" binding:"required,oneof=ntfy gotify"`
Endpoint string `json:"endpoint" binding:"required"`
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "kind (ntfy|gotify) and endpoint are required")
return
}
endpoint := strings.TrimRight(strings.TrimSpace(req.Endpoint), "/")
u, err := url.Parse(endpoint)
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
respondError(c, http.StatusBadRequest, "VALIDATION", "endpoint must be a valid http(s) URL")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
row, err := sqlcdb.New(db.DB).InsertNotificationChannel(c.Request.Context(), sqlcdb.InsertNotificationChannelParams{
UserID: uid,
Kind: req.Kind,
Endpoint: endpoint,
Token: sql.NullString{String: req.Token, Valid: req.Token != ""},
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create channel")
return
}
c.JSON(http.StatusCreated, gin.H{"channel": mapChannel(row)})
}
func handleDeleteNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
n, err := sqlcdb.New(db.DB).DeleteNotificationChannel(c.Request.Context(), sqlcdb.DeleteNotificationChannelParams{
ID: id, UserID: uid,
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete channel")
return
}
if n == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
return
}
c.JSON(http.StatusOK, gin.H{"message": "Channel deleted"})
}
// handleTestNotificationChannel pushes a probe notification through the
// channel — surfaces misconfiguration before real alerts depend on it.
func handleTestNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load channel")
return
}
var target *sqlcdb.NotificationChannel
for i := range rows {
if rows[i].ID == id {
target = &rows[i]
break
}
}
if target == nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
return
}
if err := deliverPush(c.Request.Context(), *target, "test", "Containr test notification", "Your push channel is configured correctly."); err != nil {
respondError(c, http.StatusBadGateway, "DELIVERY_FAILED", fmt.Sprintf("Push failed: %v", err))
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// deliverPush sends one notification through a channel. ntfy expects the
// body at POST {endpoint} with Title/Priority headers; Gotify takes JSON at
// POST {endpoint}/message?token=...
func deliverPush(ctx context.Context, ch sqlcdb.NotificationChannel, kind, title, body string) error {
var req *http.Request
var err error
switch ch.Kind {
case "ntfy":
req, err = http.NewRequestWithContext(ctx, http.MethodPost, ch.Endpoint, strings.NewReader(body))
if err == nil {
req.Header.Set("Title", title)
req.Header.Set("Tags", "containr,"+kind)
if ch.Token.Valid && ch.Token.String != "" {
req.Header.Set("Authorization", "Bearer "+ch.Token.String)
}
}
case "gotify":
payload, _ := json.Marshal(map[string]interface{}{
"title": title,
"message": body,
"priority": 5,
})
endpoint := ch.Endpoint + "/message"
if ch.Token.Valid && ch.Token.String != "" {
endpoint += "?token=" + url.QueryEscape(ch.Token.String)
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
default:
return fmt.Errorf("unsupported channel kind %q", ch.Kind)
}
if err != nil {
return err
}
res, err := pushHTTPClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("push endpoint returned %d", res.StatusCode)
}
return nil
}
// fanoutUserPush delivers a notification to every enabled channel the user
// configured. Errors are swallowed — the in-app row already landed.
func fanoutUserPush(db *database.DB, userID, kind, title, body string) {
uid, err := uuid.Parse(userID)
if err != nil {
return
}
rows, err := sqlcdb.New(db.DB).ListNotificationChannelsByUser(context.Background(), uid)
if err != nil || len(rows) == 0 {
return
}
for _, ch := range rows {
_ = deliverPush(context.Background(), ch, kind, title, body)
}
}
@@ -0,0 +1,80 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"containr/internal/database/sqlcdb"
)
func TestDeliverPushNtfy(t *testing.T) {
var gotAuth, gotTitle, gotTags, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotTitle = r.Header.Get("Title")
gotTags = r.Header.Get("Tags")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{
Kind: "ntfy",
Endpoint: srv.URL + "/alerts",
Token: sql.NullString{String: "tk_abc", Valid: true},
}
if err := deliverPush(context.Background(), ch, "deployment", "Deploy done", "svc is live"); err != nil {
t.Fatalf("deliverPush: %v", err)
}
if gotAuth != "Bearer tk_abc" {
t.Fatalf("auth header = %q", gotAuth)
}
if gotTitle != "Deploy done" || gotTags != "containr,deployment" || gotBody != "svc is live" {
t.Fatalf("title=%q tags=%q body=%q", gotTitle, gotTags, gotBody)
}
}
func TestDeliverPushGotify(t *testing.T) {
var gotPath, gotToken string
var payload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotToken = r.URL.Query().Get("token")
_ = json.NewDecoder(r.Body).Decode(&payload)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{
Kind: "gotify",
Endpoint: srv.URL,
Token: sql.NullString{String: "apptoken", Valid: true},
}
if err := deliverPush(context.Background(), ch, "backup", "Backup done", "db backup ok"); err != nil {
t.Fatalf("deliverPush: %v", err)
}
if gotPath != "/message" || gotToken != "apptoken" {
t.Fatalf("path=%q token=%q", gotPath, gotToken)
}
if payload["title"] != "Backup done" || payload["message"] != "db backup ok" {
t.Fatalf("payload = %v", payload)
}
}
func TestDeliverPushFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{Kind: "ntfy", Endpoint: srv.URL}
if err := deliverPush(context.Background(), ch, "k", "t", "b"); err == nil {
t.Fatal("expected error on 500")
}
}
@@ -9,6 +9,7 @@ import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
@@ -29,15 +30,13 @@ func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID s
if db == nil {
return
}
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
adminIDs, err := sqlcdb.New(db.DB).ListAdminUserIDs(context.Background())
if err != nil {
log.Printf("containr: notify admins lookup failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err == nil {
for _, id := range adminIDs {
{
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
}
}
@@ -73,34 +72,32 @@ type agentHealthRow struct {
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
cutoff := time.Now().Add(-staleAfter)
var agents []agentHealthRow
rows, err := db.Query(
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
COALESCE(last_heartbeat < $1, TRUE), auto_prune
FROM node_agents`, cutoff)
q := sqlcdb.New(db.DB)
agentRows, err := q.ListAgentHealthRows(context.Background(), sql.NullTime{Time: cutoff, Valid: true})
if err != nil {
log.Printf("containr: agent health sweep: %v", err)
return
}
for rows.Next() {
var a agentHealthRow
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
agents = append(agents, a)
}
for _, r := range agentRows {
agents = append(agents, agentHealthRow{
id: r.ID, name: r.Name, status: r.Status, stale: r.Stale, autoPrune: r.AutoPrune,
})
}
rows.Close()
for _, a := range agents {
switch {
case a.stale && a.status != "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
Status: sql.NullString{String: "offline", Valid: true}, ID: a.id,
}); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent offline",
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
case !a.stale && a.status == "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
Status: sql.NullString{String: "online", Valid: true}, ID: a.id,
}); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent back online",
@@ -186,17 +183,17 @@ func checkForUpgrade(db *database.DB, currentVersion string) {
return
}
var lastNotified string
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
lastNotified, _ := sqlcdb.New(db.DB).GetAppSetting(context.Background(), "upgrade_notified_tag")
if lastNotified == release.TagName {
return
}
notifyAdmins(db, "upgrade", "Containr update available",
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
"release", release.TagName)
_, _ = db.Exec(
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
_ = sqlcdb.New(db.DB).UpsertAppSettingValue(context.Background(), sqlcdb.UpsertAppSettingValueParams{
Key: "upgrade_notified_tag",
Value: release.TagName,
})
}
// semverNewer reports whether candidate is a higher semver than current.
@@ -41,6 +41,7 @@ func insertUserNotification(db *database.DB, userID, kind, title, body, resource
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
})
go fanoutUserPush(db, userID, kind, title, body)
}
func mapNotification(row sqlcdb.Notification) NotificationItem {
+40 -69
View File
@@ -1,13 +1,16 @@
package api
import (
"context"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GET /operations — a single cross-cutting view of platform work: active
@@ -55,91 +58,59 @@ func handleGetOperations(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
isAdmin := contextIsAdmin(c)
// Same visibility rule as the deployment list endpoint.
visibility := `p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`
userUUID, _ := uuid.Parse(userID)
q := sqlcdb.New(db.DB)
visibility := sqlcdb.ListActiveOperationDeploymentsParams{OwnerID: userUUID, Column2: isAdmin}
active := []operationDeployment{}
rows, err := db.Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
AND (`+visibility+`)
ORDER BY d.created_at ASC`, userID, isAdmin)
if err == nil {
defer rows.Close()
for rows.Next() {
var d operationDeployment
var sid string
_ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
active = append(active, d)
if rows, err := q.ListActiveOperationDeployments(context.Background(), visibility); err == nil {
for _, r := range rows {
active = append(active, operationDeployment{
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
})
}
}
recentFailed := []operationDeployment{}
rows2, err2 := db.Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('failed','cancelled','rolled_back')
AND d.updated_at > NOW() - INTERVAL '24 hours'
AND (`+visibility+`)
ORDER BY d.updated_at DESC
LIMIT 20`, userID, isAdmin)
if err2 == nil {
defer rows2.Close()
for rows2.Next() {
var d operationDeployment
var sid string
_ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
recentFailed = append(recentFailed, d)
if rows, err := q.ListRecentFailedDeployments(context.Background(), sqlcdb.ListRecentFailedDeploymentsParams{
OwnerID: userUUID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
recentFailed = append(recentFailed, operationDeployment{
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
})
}
}
cronRuns := []operationCronRun{}
rows3, err3 := db.Query(
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error
FROM cron_executions e
JOIN cron_jobs j ON j.id = e.cron_job_id
JOIN projects p ON p.id = j.project_id
WHERE e.started_at > NOW() - INTERVAL '24 hours'
AND (`+visibility+`)
ORDER BY e.started_at DESC
LIMIT 30`, userID, isAdmin)
if err3 == nil {
defer rows3.Close()
for rows3.Next() {
var r operationCronRun
_ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error)
cronRuns = append(cronRuns, r)
if rows, err := q.ListRecentCronRuns(context.Background(), sqlcdb.ListRecentCronRunsParams{
OwnerID: userUUID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
cronRuns = append(cronRuns, operationCronRun{
ID: r.ID.String(), JobName: r.JobName, Schedule: r.Schedule, Status: r.Status.String,
StartedAt: r.StartedAt, FinishedAt: timePtrNT(r.FinishedAt), Error: strPtrNS(r.Error),
})
}
}
backups := []operationBackup{}
rows4, err4 := db.Query(
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services ds ON ds.id = b.database_id
WHERE b.created_at > NOW() - INTERVAL '24 hours'
AND (ds.user_id = $1 OR $2::bool)
ORDER BY b.created_at DESC
LIMIT 20`, userID, isAdmin)
if err4 == nil {
defer rows4.Close()
for rows4.Next() {
var b operationBackup
_ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed)
backups = append(backups, b)
if rows, err := q.ListRecentOperationBackups(context.Background(), sqlcdb.ListRecentOperationBackupsParams{
UserID: userID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
backups = append(backups, operationBackup{
ID: r.ID, DatabaseID: r.DatabaseID, DBName: r.DbName, Status: r.Status,
Size: r.Size, CreatedAt: r.CreatedAt.Time, Completed: timePtrNT(r.CompletedAt),
})
}
}
var queue []deployqueue.ServiceQueueState
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
queue = queueValue.(*deployqueue.Queue).Snapshot()
+419 -258
View File
@@ -2,7 +2,12 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -26,11 +31,77 @@ type PreviewEnvironment struct {
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// PreviewServiceID is the cloned service this preview actually deploys;
// its status is the source of truth while the preview is active.
PreviewServiceID *uuid.UUID `json:"preview_service_id,omitempty" db:"preview_service_id"`
// Related data
Service *Service `json:"service,omitempty"`
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
}
// previewRowShape is the common column set the generated preview rows
// carry — ListPreviewEnvironmentsForProjectRow and GetPreviewEnvironmentRow
// share it.
type previewRowShape struct {
ID uuid.UUID
ProjectID uuid.UUID
ServiceID uuid.UUID
PreviewServiceID uuid.NullUUID
BranchName string
PrNumber sql.NullInt32
Environment string
Status string
Url sql.NullString
ExpiresAt time.Time
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
SvcID uuid.NullUUID
ServiceName sql.NullString
ServiceType sql.NullString
}
func previewFromRow(r previewRowShape) PreviewEnvironment {
env := PreviewEnvironment{
ID: r.ID,
ProjectID: r.ProjectID,
ServiceID: r.ServiceID,
BranchName: r.BranchName,
Environment: r.Environment,
Status: r.Status,
URL: r.Url.String,
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
if r.PreviewServiceID.Valid {
id := r.PreviewServiceID.UUID
env.PreviewServiceID = &id
}
if r.PrNumber.Valid {
n := int(r.PrNumber.Int32)
env.PRNumber = &n
}
if !r.ExpiresAt.IsZero() {
t := r.ExpiresAt
env.ExpiresAt = &t
}
if r.SvcID.Valid {
env.Service = &Service{
ID: r.SvcID.UUID,
Name: r.ServiceName.String,
Type: r.ServiceType.String,
}
}
return env
}
func prNumberSQL(n *int) sql.NullInt32 {
if n == nil {
return sql.NullInt32{}
}
return sql.NullInt32{Int32: int32(*n), Valid: true}
}
// CreatePreviewEnvironmentRequest represents a request to create a preview environment
type CreatePreviewEnvironmentRequest struct {
ProjectID uuid.UUID `json:"project_id"`
@@ -77,51 +148,21 @@ func handleGetPreviewEnvironments(c *gin.Context) {
}
// Get preview environments for the project with service info
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1
ORDER BY pe.created_at DESC`,
projectID,
)
rows, err := sqlcdb.New(db.(*database.DB).DB).ListPreviewEnvironmentsForProject(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve preview environments"})
return
}
defer rows.Close()
var environments []PreviewEnvironment
for rows.Next() {
var env PreviewEnvironment
var serviceID sql.NullString
var serviceName sql.NullString
var serviceType sql.NullString
err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan preview environment"})
return
}
if serviceID.Valid {
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
if parseErr == nil {
env.Service = &Service{
ID: parsedServiceID,
Name: serviceName.String,
Type: serviceType.String,
}
}
}
environments = append(environments, env)
environments := make([]PreviewEnvironment, 0, len(rows))
for _, r := range rows {
environments = append(environments, previewFromRow(previewRowShape{
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
PreviewServiceID: r.PreviewServiceID, BranchName: r.BranchName,
PrNumber: r.PrNumber, Environment: r.Environment, Status: r.Status,
Url: r.Url, ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
SvcID: r.SvcID, ServiceName: r.ServiceName, ServiceType: r.ServiceType,
}))
}
c.JSON(http.StatusOK, gin.H{"preview_environments": environments})
@@ -162,48 +203,37 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
req.ProjectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
// Check if project exists and user has access
project, err := q.GetProjectBrief(ctx, req.ProjectID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
if project.OwnerID.String() != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Check if service exists and belongs to the project
var service Service
err = db.(*database.DB).QueryRow(
"SELECT id, name, COALESCE(type, service_type, '') FROM services WHERE id = $1 AND project_id = $2",
req.ServiceID, req.ProjectID,
).Scan(&service.ID, &service.Name, &service.Type)
service, err := q.GetServiceTypeBrief(ctx, sqlcdb.GetServiceTypeBriefParams{
ID: req.ServiceID, ProjectID: req.ProjectID})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found or doesn't belong to this project"})
return
}
// Check if preview environment already exists for this branch and service
var count int
err = db.(*database.DB).QueryRow(
"SELECT COUNT(*) FROM preview_environments WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped')",
req.ServiceID, req.BranchName,
).Scan(&count)
count, err := q.CountActivePreviewsForBranch(ctx, sqlcdb.CountActivePreviewsForBranchParams{
ServiceID: req.ServiceID, BranchName: req.BranchName})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing preview environment"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Preview environment already exists for this branch and service"})
return
@@ -215,87 +245,111 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
ttlHours = 24 // Default 24 hours
}
// Create preview environment
// A preview is a real service: clone the source, pin it to the
// requested branch, swap its domains for a preview hostname, and run a
// normal deployment through the queue. Expiry sweeps the clone away.
envName := generatePreviewEnvironmentName(req.BranchName)
cloneName := previewServiceName(service.Name, req.BranchName)
cloneID, err := cloneServiceRow(db.(*database.DB), req.ServiceID, req.ProjectID, cloneName, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service for preview"})
return
}
// The clone's branch override is what makes it a preview — everything
// else (env vars, volumes, builder, resources) stays identical.
if err := q.UpdateServicePreviewBranch(ctx, sqlcdb.UpdateServicePreviewBranchParams{
GitBranch: sql.NullString{String: req.BranchName, Valid: true}, ID: cloneID}); err != nil {
_ = q.DeleteServiceByID(ctx, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview service"})
return
}
// Cloned service_domains would collide with the parent's Traefik
// router — previews get their own hostname or none.
if err := q.DeleteAllServiceDomains(ctx, cloneID); err != nil {
_ = q.DeleteServiceByID(ctx, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview domains"})
return
}
previewHost := resolvePreviewHost(db.(*database.DB), cloneID, envName)
if previewHost != "" {
_ = q.CreatePreviewDomain(ctx, sqlcdb.CreatePreviewDomainParams{
ServiceID: cloneID, Domain: previewHost})
}
expiresAt := time.Now().Add(time.Duration(ttlHours) * time.Hour)
env := PreviewEnvironment{
ID: uuid.New(),
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
BranchName: req.BranchName,
PRNumber: req.PRNumber,
Environment: generatePreviewEnvironmentName(req.BranchName),
Environment: envName,
Status: "building",
ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0],
ExpiresAt: &expiresAt,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if previewHost != "" {
env.URL = "https://" + previewHost
}
// Insert preview environment into database
_, err = db.(*database.DB).Exec(
`INSERT INTO preview_environments
(id, project_id, service_id, branch_name, pr_number, environment,
status, url, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber,
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
)
err = q.CreatePreviewEnvironment(ctx, sqlcdb.CreatePreviewEnvironmentParams{
ID: env.ID,
ProjectID: env.ProjectID,
ServiceID: env.ServiceID,
PreviewServiceID: uuid.NullUUID{UUID: cloneID, Valid: true},
BranchName: env.BranchName,
PrNumber: prNumberSQL(env.PRNumber),
Environment: env.Environment,
Status: env.Status,
Url: sql.NullString{String: env.URL, Valid: env.URL != ""},
ExpiresAt: *env.ExpiresAt,
CreatedAt: sql.NullTime{Time: env.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: env.UpdatedAt, Valid: true},
})
if err != nil {
_ = q.DeleteServiceByID(ctx, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
return
}
deploymentID := uuid.New()
sanitizedBranch := strings.ReplaceAll(req.BranchName, "/", "-")
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-")
version := fmt.Sprintf("preview-%s-%d", sanitizedBranch, time.Now().Unix())
startedAt := time.Now().UTC()
completedAt := startedAt
_, deployErr := db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, started_at, completed_at, deployment_log, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())`,
deploymentID,
req.ServiceID,
version,
req.BranchName,
"running",
startedAt,
completedAt,
fmt.Sprintf("Preview environment %s activated for branch %s", env.Environment, req.BranchName),
)
if deployErr != nil {
_, _ = db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = 'failed', updated_at = NOW()
WHERE id = $1`,
env.ID,
)
err = q.InsertPendingDeployment(ctx, sqlcdb.InsertPendingDeploymentParams{
ID: deploymentID,
ServiceID: cloneID,
Version: "preview-" + env.Environment,
CommitHash: sql.NullString{String: req.BranchName, Valid: true},
})
if err != nil {
_ = q.DeletePreviewEnvironment(ctx, env.ID)
_ = q.DeleteServiceByID(ctx, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"})
return
}
if strings.TrimSpace(env.URL) == "" {
env.URL = fmt.Sprintf("https://%s.preview.containr.local", env.Environment)
}
env.Status = "running"
env.UpdatedAt = time.Now().UTC()
env.DeploymentID = &deploymentID
_, err = db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = $1, url = $2, updated_at = $3
WHERE id = $4`,
env.Status,
env.URL,
env.UpdatedAt,
env.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize preview environment"})
return
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID)
if loadErr == nil {
engineInstance := engine
d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{
DeploymentID: deploymentID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string))
},
})
}
}
}
LogAuditWithRequest(c, "service", req.ServiceID.String(), "preview.create", map[string]interface{}{
"preview_environment_id": env.ID.String(),
"preview_service_id": cloneID.String(),
"branch": req.BranchName,
})
c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
}
@@ -316,43 +370,20 @@ func handleGetPreviewEnvironment(c *gin.Context) {
// Public when the parent project is approved; otherwise owner/member/admin.
userID := optionalUserUUID(c)
var env PreviewEnvironment
var serviceID sql.NullString
var serviceName sql.NullString
var serviceType sql.NullString
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType,
)
r, err := sqlcdb.New(db.(*database.DB).DB).GetPreviewEnvironment(
c.Request.Context(), sqlcdb.GetPreviewEnvironmentParams{
ID: envID, OwnerID: userID, Column3: contextIsAdmin(c)})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Populate service info if available
if serviceID.Valid {
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
if parseErr == nil {
env.Service = &Service{
ID: parsedServiceID,
Name: serviceName.String,
Type: serviceType.String,
}
}
}
env := previewFromRow(previewRowShape{
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
PreviewServiceID: r.PreviewServiceID, BranchName: r.BranchName,
PrNumber: r.PrNumber, Environment: r.Environment, Status: r.Status,
Url: r.Url, ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
SvcID: r.SvcID, ServiceName: r.ServiceName, ServiceType: r.ServiceType,
})
c.JSON(http.StatusOK, gin.H{"preview_environment": env})
}
@@ -385,24 +416,21 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
}
// Check if preview environment exists and user has access
var existingEnv PreviewEnvironment
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
&existingEnv.ExpiresAt, &existingEnv.CreatedAt, &existingEnv.UpdatedAt,
)
uid, _ := uuid.Parse(fmt.Sprint(userID))
q := sqlcdb.New(db.(*database.DB).DB)
r, err := q.GetPreviewEnvironmentForWrite(
c.Request.Context(), sqlcdb.GetPreviewEnvironmentForWriteParams{
ID: envID, OwnerID: uid, Column3: contextIsAdmin(c)})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
existingEnv := previewFromRow(previewRowShape{
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
BranchName: r.BranchName, PrNumber: r.PrNumber, Environment: r.Environment,
Status: r.Status, Url: r.Url, ExpiresAt: r.ExpiresAt,
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
// Update fields if provided
if req.Status != "" {
@@ -422,12 +450,17 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
existingEnv.UpdatedAt = time.Now()
// Update preview environment in database
_, err = db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = $1, url = $2, expires_at = $3, updated_at = $4
WHERE id = $5`,
existingEnv.Status, existingEnv.URL, existingEnv.ExpiresAt, existingEnv.UpdatedAt, existingEnv.ID,
)
expiresAt := time.Time{}
if existingEnv.ExpiresAt != nil {
expiresAt = *existingEnv.ExpiresAt
}
err = q.UpdatePreviewEnvironment(c.Request.Context(), sqlcdb.UpdatePreviewEnvironmentParams{
Status: existingEnv.Status,
Url: sql.NullString{String: existingEnv.URL, Valid: existingEnv.URL != ""},
ExpiresAt: expiresAt,
UpdatedAt: sql.NullTime{Time: existingEnv.UpdatedAt, Valid: true},
ID: existingEnv.ID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment"})
@@ -459,49 +492,34 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
return
}
// Check if preview environment exists and user has access
var projectOwnerID string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1`,
envID,
).Scan(&projectOwnerID)
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
// Check if preview environment exists and user has access
projectOwnerID, err := q.GetPreviewEnvironmentOwner(ctx, envID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Check if user owns the project
if projectOwnerID != userID.(string) && !contextIsAdmin(c) {
if projectOwnerID.String() != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
cleanupID := uuid.New()
cleanupStartedAt := time.Now().UTC()
cleanupCompletedAt := cleanupStartedAt
_, _ = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, version, status, started_at, completed_at, deployment_log, created_at, updated_at)
VALUES ($1, (SELECT service_id FROM preview_environments WHERE id = $2), $3, $4, $5, $6, $7, NOW(), NOW())`,
cleanupID,
envID,
"preview-cleanup",
"rolled_back",
cleanupStartedAt,
cleanupCompletedAt,
"Preview environment resources cleaned up",
)
// Tear down the clone's runtime and row — the preview is the clone.
if cloneID, err := q.GetPreviewServiceID(ctx, envID); err == nil && cloneID.Valid {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(ctx, db.(*database.DB), engine, cloneID.UUID)
}
}
_ = q.DeleteServiceByID(ctx, cloneID.UUID)
}
// Delete preview environment
_, err = db.(*database.DB).Exec(
"DELETE FROM preview_environments WHERE id = $1",
envID,
)
err = q.DeletePreviewEnvironment(ctx, envID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete preview environment"})
@@ -539,22 +557,30 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
return
}
// Get preview environment details
var env PreviewEnvironment
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
)
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
// Get preview environment details
uid, _ := uuid.Parse(fmt.Sprint(userID))
r, err := q.GetPreviewEnvironmentForPromote(
ctx, sqlcdb.GetPreviewEnvironmentForPromoteParams{
ID: envID, OwnerID: uid, Column3: contextIsAdmin(c)})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
env := PreviewEnvironment{
ID: r.ID,
ProjectID: r.ProjectID,
ServiceID: r.ServiceID,
BranchName: r.BranchName,
Environment: r.Environment,
Status: r.Status,
}
if r.PreviewServiceID.Valid {
id := r.PreviewServiceID.UUID
env.PreviewServiceID = &id
}
// Check if preview environment is in a state that can be promoted
if env.Status != "running" {
@@ -562,39 +588,51 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
return
}
// Promote is a real redeploy: the source service builds the preview's
// branch onto its own deployment track.
deploymentID := uuid.New()
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
deploymentID, env.ServiceID, promotionVersion, "pending",
)
err = q.InsertPendingDeployment(ctx, sqlcdb.InsertPendingDeploymentParams{
ID: deploymentID,
ServiceID: env.ServiceID,
Version: promotionVersion,
CommitHash: sql.NullString{String: env.BranchName, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
return
}
if _, err := db.(*database.DB).Exec(
`UPDATE services
SET environment = $1, updated_at = NOW()
WHERE id = $2`,
req.TargetEnvironment, env.ServiceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service target environment"})
sourceService, loadErr := loadServiceForDeploy(db.(*database.DB), env.ServiceID)
if loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load promotion target service"})
return
}
previewStatus := "stopped"
if req.CreateBackup {
previewStatus = "expired"
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
engineInstance := engine
d := DeploymentModel{ID: deploymentID, ServiceID: env.ServiceID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
getDeployQueue(c).Enqueue(env.ServiceID, deployqueue.Job{
DeploymentID: deploymentID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, sourceService, CreateDeploymentRequest{Branch: env.BranchName, Trigger: "promote"}, userID.(string))
},
})
}
}
if _, err := db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = $1, updated_at = NOW()
WHERE id = $2`,
previewStatus, env.ID,
); err != nil {
// Promotion retires the preview — the clone's containers and row go.
if env.PreviewServiceID != nil {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(ctx, db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_ = q.DeleteServiceByID(ctx, *env.PreviewServiceID)
}
if err := q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
Status: "stopped", ID: env.ID}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"})
return
}
@@ -608,7 +646,7 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
"create_backup": req.CreateBackup,
"deployment_id": deploymentID,
"status": "queued",
"preview_status": previewStatus,
"preview_status": "stopped",
},
})
}
@@ -637,44 +675,46 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
return
}
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
uid, _ := uuid.Parse(fmt.Sprint(userID))
// Find expired preview environments for user's projects
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID, contextIsAdmin(c),
)
rows, err := q.ListExpiredPreviewsForUser(ctx, sqlcdb.ListExpiredPreviewsForUserParams{
OwnerID: uid, Column2: contextIsAdmin(c)})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
return
}
defer rows.Close()
var expiredEnvs []PreviewEnvironment
for rows.Next() {
var env PreviewEnvironment
err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment,
)
if err != nil {
continue
expiredEnvs := make([]PreviewEnvironment, 0, len(rows))
for _, r := range rows {
env := PreviewEnvironment{
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
BranchName: r.BranchName, Environment: r.Environment,
}
if r.PreviewServiceID.Valid {
id := r.PreviewServiceID.UUID
env.PreviewServiceID = &id
}
expiredEnvs = append(expiredEnvs, env)
}
// Mark expired environments as expired and trigger cleanup
// Mark expired environments as expired and tear down their clones
cleanupCount := 0
for _, env := range expiredEnvs {
// Update status to expired
_, err := db.(*database.DB).Exec(
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
env.ID,
)
if err != nil {
if err := q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
Status: "expired", ID: env.ID}); err != nil {
continue
}
if env.PreviewServiceID != nil {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(ctx, db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_ = q.DeleteServiceByID(ctx, *env.PreviewServiceID)
}
cleanupCount++
}
@@ -684,3 +724,124 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
"expired_environments": expiredEnvs,
})
}
// previewServiceName builds the clone's service name — deterministic enough
// to spot in lists, short enough for dnsLabel-derived hostnames.
func previewServiceName(serviceName, branch string) string {
label := dnsLabel(serviceName + "-" + branch)
if label == "" {
return "preview-" + uuid.NewString()[:8]
}
return label + "-preview"
}
// resolvePreviewHost picks a preview hostname under the clone's node default
// domain — same source as serviceAutoDomains. Returns "" when no node
// carries a default domain; the preview still runs, reachable internally.
func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) string {
label := dnsLabel(envName)
if label == "" {
return ""
}
q := sqlcdb.New(db.DB)
ctx := context.Background()
brief, err := q.GetServicePlacementBrief(ctx, cloneID)
if err != nil {
return ""
}
nodeID, spread := brief.NodeID, brief.Spread
var base string
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil {
base = a.DefaultDomain
}
case spread:
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, cloneID)))); err == nil && len(bases) > 0 {
base = bases[0]
}
default:
// Local preview: borrow the first node default domain if any —
// still better than no hostname for a Traefik-fronted local host.
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage("[]")); err == nil && len(bases) > 0 {
base = bases[0]
}
}
if base == "" {
return ""
}
return label + "." + base
}
// loadServiceForDeploy hydrates the Service fields runDeploymentAndSync
// needs — mirrors the loader in handleDeployService.
func serviceForDeployFromRow(r sqlcdb.GetServiceForDeployRow) Service {
return Service{
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name, Type: r.Type.String,
Status: r.Status.String, Image: r.Image.String, Command: r.Command.String,
Environment: r.Environment.String, GitRepo: r.GitRepo.String,
GitBranch: r.GitBranch.String, BuildPath: r.BuildPath.String,
CPU: r.Cpu.String, Memory: r.Memory.String,
Replicas: int(r.Replicas), Port: int(r.Port), Domain: r.Domain,
HealthCheckPath: r.HealthcheckPath, RestartPolicy: r.RestartPolicy,
Builder: r.Builder, CPUReserve: r.CpuReserve, MemoryReserve: r.MemoryReserve,
StaticBuildCmd: r.StaticBuildCmd, StaticDir: r.StaticDir,
CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time,
}
}
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
r, err := sqlcdb.New(db.DB).GetServiceForDeploy(context.Background(), serviceID)
if err != nil {
return Service{}, err
}
return serviceForDeployFromRow(r), nil
}
// StartPreviewSweeper expires preview environments past their TTL — tears
// down each clone's runtime and row, marks the preview expired.
func StartPreviewSweeper(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
if db == nil || engine == nil {
return
}
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sweepExpiredPreviews(ctx, db, engine)
syncPreviewStatuses(ctx, db)
}
}
}()
}
// sweepExpiredPreviews retires previews whose TTL elapsed.
func sweepExpiredPreviews(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
q := sqlcdb.New(db.DB)
rows, err := q.ListSweepablePreviews(ctx)
if err != nil {
return
}
var ids []uuid.UUID
for _, r := range rows {
ids = append(ids, r.ID)
if r.PreviewServiceID.Valid {
removeServiceRuntime(ctx, db, engine, r.PreviewServiceID.UUID)
_ = q.DeleteServiceByID(ctx, r.PreviewServiceID.UUID)
}
}
for _, id := range ids {
_ = q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
Status: "expired", ID: id})
}
}
// syncPreviewStatuses folds the clone's live service status into the
// preview row so list/detail endpoints reflect the real deploy state.
func syncPreviewStatuses(ctx context.Context, db *database.DB) {
_ = sqlcdb.New(db.DB).SyncPreviewStatuses(ctx)
}
@@ -0,0 +1,30 @@
package api
import (
"strings"
"testing"
)
func TestPreviewServiceName(t *testing.T) {
cases := []struct {
svc, branch string
want string
}{
{"whoami-web", "feature-x", "whoami-web-feature-x-preview"},
{"My App", "feat/JARV-123_foo", "my-app-feat-jarv-123-foo-preview"},
{"svc", "", "svc-preview"},
}
for _, tc := range cases {
got := previewServiceName(tc.svc, tc.branch)
if got != tc.want {
t.Fatalf("previewServiceName(%q,%q) = %q, want %q", tc.svc, tc.branch, got, tc.want)
}
if len(got) > 63 {
t.Fatalf("name exceeds DNS label limit: %q", got)
}
}
// A name that sanitizes to nothing must still be usable.
if got := previewServiceName("!!!", "%%%"); !strings.HasPrefix(got, "preview-") {
t.Fatalf("expected fallback name, got %q", got)
}
}
+17 -11
View File
@@ -1,10 +1,12 @@
package api
import (
"context"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
@@ -112,7 +114,8 @@ func handleUpdateProjectVariables(c *gin.Context) {
}
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
txq := sqlcdb.New(tx)
if err = txq.DeleteProjectVariables(c.Request.Context(), projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
}
@@ -127,11 +130,15 @@ func handleUpdateProjectVariables(c *gin.Context) {
if v.IsSecret {
value = secrets.Encrypt(value)
}
if _, err = tx.Exec(
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
); err != nil {
if err = txq.InsertProjectVariable(c.Request.Context(), sqlcdb.InsertProjectVariableParams{
ID: uuid.New(),
ProjectID: projectID,
Key: v.Key,
Value: value,
IsSecret: v.IsSecret,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
@@ -168,11 +175,10 @@ func handleUpdateProjectVariables(c *gin.Context) {
// resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
var value string
err := db.QueryRow(
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
projectID, key,
).Scan(&value)
value, err := sqlcdb.New(db.DB).GetProjectVariableValue(context.Background(), sqlcdb.GetProjectVariableValueParams{
ProjectID: projectID,
Key: key,
})
if err != nil {
return "", false
}
+9 -7
View File
@@ -1,11 +1,13 @@
package api
import (
"context"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/docker/docker/api/types/registry"
@@ -177,17 +179,17 @@ func handleDeleteRegistry(c *gin.Context) {
// bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef)
var username, password string
err := db.QueryRow(
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
ownerID, host,
).Scan(&username, &password)
ownerUUID, _ := uuid.Parse(ownerID)
row, err := sqlcdb.New(db.DB).GetRegistryAuth(context.Background(), sqlcdb.GetRegistryAuthParams{
OwnerID: ownerUUID,
Host: host,
})
if err != nil {
return registry.AuthConfig{}
}
return registry.AuthConfig{
Username: username,
Password: secrets.Decrypt(password),
Username: row.Username,
Password: secrets.Decrypt(row.Password),
ServerAddress: host,
}
}
+39 -28
View File
@@ -11,6 +11,7 @@ import (
"containr/internal/build"
"containr/internal/config"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
@@ -21,6 +22,7 @@ import (
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
@@ -83,19 +85,18 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
sid, err := uuid.Parse(serviceID)
if err != nil {
return "", 0, err
}
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
svc, err := sqlcdb.New(db.DB).GetServiceNameProjectPort(ctx, sid)
if err != nil {
return "", 0, err
}
return name, port, nil
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(svc.ProjectID)); err != nil {
return "", 0, err
}
return svc.Name, int(svc.Port), nil
})
}
haAPIManager := NewHAManager(haManager)
@@ -111,6 +112,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Sleep sweeper — scales idle services to zero and prunes wake containers.
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
StartPreviewSweeper(context.Background(), db, deploymentEngine)
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
StartAuditRetention(context.Background(), db)
@@ -136,31 +138,25 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Boot sweep: in-memory engine state dies on restart, so anything that
// looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows.
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
)
bootQ := sqlcdb.New(db.DB)
_ = bootQ.FailInterruptedDeployments(context.Background())
// Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent.
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
type encRow struct {
id string
value string
}
var toEncrypt []encRow
for encRows.Next() {
var r encRow
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
toEncrypt = append(toEncrypt, r)
if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil {
toEncrypt := 0
for _, r := range encRows {
if secrets.IsEncrypted(r.Value) {
continue
}
if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{
Value: secrets.Encrypt(r.Value), ID: r.ID,
}); err == nil {
toEncrypt++
}
}
encRows.Close()
for _, r := range toEncrypt {
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
if toEncrypt > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", toEncrypt)
}
}
@@ -269,6 +265,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// endpoint, verified against the provisioned webhook secret.
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
// Public API gateway data plane. Lives outside /api/v1 so each service's
// route_prefix (/g/<slug>) maps cleanly; auth is the caller's api_key,
// not a session.
router.Any("/g/:slug", handleGatewayProxy)
router.Any("/g/:slug/*path", handleGatewayProxy)
v1 := router.Group("/api/v1")
{
// Public routes (no authentication required)
@@ -279,6 +281,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage)
public.GET("/branding", handleGetBranding)
public.GET("/setup/status", handleSetupStatus)
// Invite accept flow — validates the link, then registers
// even while public signup is closed.
@@ -300,6 +303,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/projects", handleGetProjects)
read.GET("/projects/:id", handleGetProject)
read.GET("/projects/:id/services", handleGetServices)
read.GET("/projects/:id/environments", handleListEnvironments)
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
@@ -347,6 +351,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
{
authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile)
authed.POST("/setup/complete", handleSetupComplete)
// Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens)
@@ -356,6 +361,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
authed.GET("/notifications/channels", handleListNotificationChannels)
authed.POST("/notifications/channels", handleCreateNotificationChannel)
authed.POST("/notifications/channels/:id/test", handleTestNotificationChannel)
authed.DELETE("/notifications/channels/:id", handleDeleteNotificationChannel)
// WebSocket endpoint
authed.GET("/ws", handleWebSocket)
@@ -469,6 +478,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
authed.POST("/projects/:id/environments", handleCreateEnvironment)
authed.DELETE("/environments/:id", handleDeleteEnvironment)
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
+106 -197
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"database/sql"
"fmt"
"net/http"
"regexp"
@@ -10,6 +11,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/secrets"
@@ -22,22 +24,17 @@ import (
// service — covers pinned placements, spread replicas, and leftovers from a
// since-cleared pin.
func remoteAgentsForService(ctx context.Context, db *database.DB, serviceID uuid.UUID) []string {
rows, err := db.Query(
`SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1`,
serviceID,
)
ids, err := sqlcdb.New(db.DB).ListNodeAgentsForService(ctx, serviceID.String())
if err != nil {
return nil
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil && id != "" {
ids = append(ids, id)
out := make([]string, 0, len(ids))
for _, id := range ids {
if id != "" {
out = append(out, id)
}
}
return ids
return out
}
// serviceLifecycle routes a start/stop/restart action to every agent holding
@@ -99,7 +96,9 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
// Best effort: reuse the host port from the last live deployment so the
// public URL survives restarts. Column exists post-migration; older DBs
// silently get ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort)
if p, err := sqlcdb.New(db.DB).GetServicePublishedPort(context.Background(), service.ID); err == nil {
spec.PublishedPort = p
}
if cmd := strings.TrimSpace(service.Command); cmd != "" {
spec.Command = strings.Fields(cmd)
}
@@ -121,6 +120,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
spec.TraefikLabels = serviceTraefikLabels(db, service.ID)
if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db)
}
@@ -136,42 +136,23 @@ var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-
// secrets) and expands ${{service.KEY}} references. KEY may be a variable on
// the referenced service, or the builtins HOST (service name alias) and PORT.
func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) {
rows, err := db.Query(
`SELECT key, value FROM environment_variables WHERE service_id = $1`,
service.ID,
)
q := sqlcdb.New(db.DB)
vars, err := q.ListServiceVariableValues(context.Background(), service.ID)
if err != nil {
return nil, err
}
defer rows.Close()
env := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
env[k] = secrets.Decrypt(v)
for _, v := range vars {
env[v.Key] = secrets.Decrypt(v.Value)
}
var refServices []struct{ ID, Name string }
svcRows, err := db.Query(
`SELECT id, name FROM services WHERE project_id = $1`,
service.ProjectID,
)
refServices, err := q.ListProjectServiceRefs(context.Background(), service.ProjectID)
if err != nil {
return nil, err
}
defer svcRows.Close()
for svcRows.Next() {
var s struct{ ID, Name string }
if err := svcRows.Scan(&s.ID, &s.Name); err != nil {
return nil, err
}
refServices = append(refServices, s)
}
byName := map[string]string{}
byName := map[string]uuid.UUID{}
for _, s := range refServices {
byName[strings.ToLower(s.Name)] = s.ID
}
@@ -188,17 +169,15 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
case "HOST":
return serviceName, true
case "PORT":
var port int
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil && port > 0 {
return strconv.Itoa(port), true
if port, err := q.GetServicePort(context.Background(), id); err == nil && port > 0 {
return strconv.Itoa(int(port)), true
}
return "", false
}
var value string
err := db.QueryRow(
`SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`,
id, key,
).Scan(&value)
value, err := q.GetServiceVariableValue(context.Background(), sqlcdb.GetServiceVariableValueParams{
ServiceID: id,
Key: key,
})
if err != nil {
return "", false
}
@@ -271,6 +250,15 @@ func parseMemoryLimit(mem string) int64 {
return 0
}
// setServiceStatusNow writes the stored status with a fresh updated_at.
func setServiceStatusNow(db *database.DB, id uuid.UUID, status string) {
_ = sqlcdb.New(db.DB).SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: status, Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: id,
})
}
func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) {
dbValue, exists := c.Get("db")
if !exists {
@@ -285,6 +273,22 @@ func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.D
return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true
}
// serviceFromRuntimeRow maps the sqlc runtime row (with legacy-column
// fallbacks applied in SQL) onto the API Service model.
func serviceFromRuntimeRow(r sqlcdb.GetServiceRuntimeWithOwnerRow) Service {
return Service{
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name, Type: r.Type,
Status: r.Status, Image: r.Image, Command: r.Command,
Environment: r.Environment, GitRepo: r.GitRepo, GitBranch: r.GitBranch,
BuildPath: r.BuildPath, CPU: r.Cpu, Memory: r.Memory,
Replicas: int(r.Replicas), Port: int(r.Port), Domain: r.Domain,
HealthCheckPath: r.HealthcheckPath, RestartPolicy: r.RestartPolicy,
Builder: r.Builder, CPUReserve: r.CpuReserve,
MemoryReserve: r.MemoryReserve, StaticBuildCmd: r.StaticBuildCmd,
StaticDir: r.StaticDir, CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time,
}
}
func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -297,43 +301,16 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
return Service{}, false
}
var service Service
var owner string
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
)
row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
if owner != userID.(string) && !isAdminUser(db, userID.(string)) {
if row.OwnerID.String() != userID.(string) && !isAdminUser(db, userID.(string)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return Service{}, false
}
return service, true
return serviceFromRuntimeRow(row), true
}
// loadReadableService loads a service when the caller may read its project:
@@ -345,39 +322,13 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
return Service{}, false
}
var service Service
var projectID uuid.UUID
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, s.project_id
FROM services s
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectID,
)
row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
service := serviceFromRuntimeRow(row)
if _, allowed := projectReadAccess(c, db, service.ProjectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
@@ -428,7 +379,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab
status = state.Status
persistPublishedPort(db, service.ID, state.Ports)
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, status)
c.JSON(http.StatusOK, gin.H{"runtime": state})
return true
}
@@ -447,7 +398,7 @@ func handleServiceStart(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, "running")
c.JSON(http.StatusOK, gin.H{"status": "running"})
}
@@ -465,7 +416,7 @@ func handleServiceStop(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_, _ = db.Exec(`UPDATE services SET status = 'stopped', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, "stopped")
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
}
@@ -484,7 +435,7 @@ func handleServiceRestart(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, "running")
c.JSON(http.StatusOK, gin.H{"status": "running"})
}
@@ -504,12 +455,8 @@ func handleServiceRedeploy(c *gin.Context) {
image := service.Image
if image == "" {
// Git-sourced service: reuse the last successful deployment image.
err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image)
var err error
image, err = sqlcdb.New(db.DB).GetLastDeployedImage(context.Background(), service.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"})
return
@@ -555,7 +502,7 @@ func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deploymen
if state.Status != "" {
status = state.Status
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, status)
}
return state, nil
}
@@ -574,12 +521,9 @@ func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bo
}
image := service.Image
if image == "" {
if err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image); err != nil {
var err error
image, err = sqlcdb.New(db.DB).GetLastDeployedImage(context.Background(), service.ID)
if err != nil {
return false
}
}
@@ -627,10 +571,10 @@ func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16)
if len(ports) == 0 {
return
}
_, _ = db.Exec(
`UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`,
int(ports[0]), serviceID,
)
_ = sqlcdb.New(db.DB).SetServicePublishedPort(context.Background(), sqlcdb.SetServicePublishedPortParams{
PublishedPort: int32(ports[0]),
ID: serviceID,
})
}
// liveServiceStatus reconciles the stored status with real container state.
@@ -653,11 +597,13 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
}
if state.Status != service.Status {
service.Status = state.Status
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, state.Status)
}
persistPublishedPort(db, service.ID, state.Ports)
if service.Domain != "" {
service.PublicURL = "https://" + service.Domain
} else if auto := serviceAutoDomains(db, service.ID); len(auto) > 0 {
service.PublicURL = "https://" + auto[0]
} else if len(state.URLs) > 0 {
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
}
@@ -673,44 +619,22 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
return fmt.Errorf("runtime unavailable")
}
var service Service
var owner string
err := db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`, serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
)
q := sqlcdb.New(db.DB)
sid, err := uuid.Parse(serviceID)
if err != nil {
return err
}
row, err := q.GetServiceRuntimeWithOwner(context.Background(), sid)
if err != nil {
return err
}
service := serviceFromRuntimeRow(row)
if service.Image == "" {
if err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`, service.ID,
).Scan(&service.Image); err != nil {
image, err := q.GetLastDeployedImage(context.Background(), service.ID)
if err != nil {
return fmt.Errorf("no deployed image to scale")
}
service.Image = image
}
spec, err := serviceRuntimeSpec(db, service)
@@ -728,7 +652,11 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
if state != nil {
persistPublishedPort(db, service.ID, state.Ports)
}
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
_ = q.SetServiceReplicas(context.Background(), sqlcdb.SetServiceReplicasParams{
Replicas: int32(replicas),
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: service.ID,
})
return nil
}
@@ -747,47 +675,34 @@ func handleServiceEnvCheck(c *gin.Context) {
return
}
rows, err := db.Query(
`SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`,
service.ID,
)
q := sqlcdb.New(db.DB)
vars, err := q.ListServiceVariablesWithSecret(context.Background(), service.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var unresolved, empty, unreadable []string
var raw []struct{ key, val string }
for rows.Next() {
var k, v string
var secret bool
if err := rows.Scan(&k, &v, &secret); err != nil {
continue
}
decrypted := secrets.Decrypt(v)
if secret && secrets.IsEncrypted(v) && decrypted == v {
for _, v := range vars {
decrypted := secrets.Decrypt(v.Value)
if v.IsSecret && secrets.IsEncrypted(v.Value) && decrypted == v.Value {
// Ciphertext failed to decrypt — Decrypt returns the input.
unreadable = append(unreadable, k)
unreadable = append(unreadable, v.Key)
continue
}
if strings.TrimSpace(decrypted) == "" {
empty = append(empty, k)
empty = append(empty, v.Key)
}
raw = append(raw, struct{ key, val string }{k, decrypted})
raw = append(raw, struct{ key, val string }{v.Key, decrypted})
}
// Project siblings for ${{name.KEY}} resolution.
siblingIDs := map[string]string{}
svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID)
if err == nil {
for svcRows.Next() {
var id, name string
if err := svcRows.Scan(&id, &name); err == nil {
siblingIDs[strings.ToLower(name)] = id
}
siblingIDs := map[string]uuid.UUID{}
if refs, err := q.ListProjectServiceRefs(context.Background(), service.ProjectID); err == nil {
for _, s := range refs {
siblingIDs[strings.ToLower(s.Name)] = s.ID
}
svcRows.Close()
}
resolvable := func(serviceName, key string) bool {
@@ -799,20 +714,14 @@ func handleServiceEnvCheck(c *gin.Context) {
return true
}
if strings.EqualFold(key, "PORT") {
var port int
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil {
return port > 0
}
return false
port, err := q.GetServicePort(context.Background(), id)
return err == nil && port > 0
}
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`,
id, key,
).Scan(&n); err == nil {
return n > 0
}
return false
_, err := q.GetServiceVariableValue(context.Background(), sqlcdb.GetServiceVariableValueParams{
ServiceID: id,
Key: key,
})
return err == nil
}
for _, kv := range raw {
+70 -109
View File
@@ -2,7 +2,9 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/security"
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -33,8 +35,8 @@ func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler
// Notify the project owner when a background scan completes — criticals
// earn a distinct title so they stand out in the notification list.
scanner.OnScanComplete = func(scan *security.SecurityScan) {
var ownerID string
if err := db.QueryRow(`SELECT owner_id::text FROM projects WHERE id = $1`, scan.ProjectID).Scan(&ownerID); err != nil || ownerID == "" {
ownerID, err := sqlcdb.New(db.DB).GetProjectOwnerText(context.Background(), uuid.MustParse(scan.ProjectID))
if err != nil || ownerID == "" {
return
}
title := "Security scan completed"
@@ -81,14 +83,10 @@ func (sh *SecurityHandler) StartSecurityScan(c *gin.Context) {
return
}
var serviceExists bool
err := sh.db.QueryRow(
`SELECT EXISTS(
SELECT 1 FROM services WHERE id = $1 AND project_id = $2
)`,
req.ServiceID,
req.ProjectID,
).Scan(&serviceExists)
serviceExists, err := sqlcdb.New(sh.db.DB).ServiceExistsInProject(context.Background(), sqlcdb.ServiceExistsInProjectParams{
ID: uuid.MustParse(req.ServiceID),
ProjectID: uuid.MustParse(req.ProjectID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate service"})
return
@@ -164,40 +162,30 @@ func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) {
}
// Query vulnerabilities
rows, err := sh.db.Query(`
SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at
FROM vulnerabilities
WHERE project_id = $1
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
found_at DESC
`, projectID)
rows, err := sqlcdb.New(sh.db.DB).ListVulnerabilities(context.Background(), uuid.MustParse(projectID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerabilities"})
return
}
defer rows.Close()
var vulnerabilities []security.Vulnerability
for rows.Next() {
var vuln security.Vulnerability
var resolvedAt *time.Time
err := rows.Scan(&vuln.ID, &vuln.Type, &vuln.Severity, &vuln.Title, &vuln.Description,
&vuln.ServiceID, &vuln.Status, &vuln.FoundAt, &resolvedAt)
if err != nil {
for _, row := range rows {
// NULL description/service_id failed the original scan and dropped
// the row — keep the same filter.
if !row.Description.Valid || !row.ServiceID.Valid {
continue
}
vuln.ResolvedAt = resolvedAt
vulnerabilities = append(vulnerabilities, vuln)
vulnerabilities = append(vulnerabilities, security.Vulnerability{
ID: row.ID.String(),
Type: row.Type,
Severity: row.Severity,
Title: row.Title,
Description: row.Description.String,
ServiceID: row.ServiceID.UUID.String(),
Status: row.Status,
FoundAt: row.FoundAt,
ResolvedAt: timePtrNT(row.ResolvedAt),
})
}
c.JSON(http.StatusOK, gin.H{"vulnerabilities": vulnerabilities})
@@ -227,11 +215,11 @@ func (sh *SecurityHandler) UpdateVulnerability(c *gin.Context) {
resolvedAt = &now
}
_, err := sh.db.Exec(`
UPDATE vulnerabilities
SET status = $1, resolved_at = $2
WHERE id = $3
`, req.Status, resolvedAt, vulnID)
err := sqlcdb.New(sh.db.DB).UpdateVulnerabilityStatus(context.Background(), sqlcdb.UpdateVulnerabilityStatusParams{
Status: req.Status,
ResolvedAt: ntPtr(resolvedAt),
ID: uuid.MustParse(vulnID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vulnerability"})
@@ -271,13 +259,7 @@ func (sh *SecurityHandler) StartComplianceAssessment(c *gin.Context) {
return
}
var frameworkExists bool
err := sh.db.QueryRow(
`SELECT EXISTS(
SELECT 1 FROM compliance_frameworks WHERE id = $1
)`,
req.FrameworkID,
).Scan(&frameworkExists)
frameworkExists, err := sqlcdb.New(sh.db.DB).ComplianceFrameworkExists(context.Background(), uuid.MustParse(req.FrameworkID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate framework"})
return
@@ -321,30 +303,25 @@ func (sh *SecurityHandler) GetComplianceReport(c *gin.Context) {
// GetComplianceFrameworks retrieves available compliance frameworks
func (sh *SecurityHandler) GetComplianceFrameworks(c *gin.Context) {
rows, err := sh.db.Query(`
SELECT id, name, description, version, enabled, created_at
FROM compliance_frameworks
WHERE enabled = true
ORDER BY name
`)
rows, err := sqlcdb.New(sh.db.DB).ListComplianceFrameworks(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance frameworks"})
return
}
defer rows.Close()
var frameworks []security.ComplianceFramework
for rows.Next() {
var framework security.ComplianceFramework
err := rows.Scan(&framework.ID, &framework.Name, &framework.Description,
&framework.Version, &framework.Enabled, &framework.CreatedAt)
if err != nil {
for _, row := range rows {
if !row.Description.Valid {
continue
}
frameworks = append(frameworks, framework)
frameworks = append(frameworks, security.ComplianceFramework{
ID: row.ID.String(),
Name: row.Name,
Description: row.Description.String,
Version: row.Version,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"frameworks": frameworks})
@@ -385,23 +362,19 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
Resolved int `json:"resolved"`
}
err := sh.db.QueryRow(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE severity = 'critical') as critical,
COUNT(*) FILTER (WHERE severity = 'high') as high,
COUNT(*) FILTER (WHERE severity = 'medium') as medium,
COUNT(*) FILTER (WHERE severity = 'low') as low,
COUNT(*) FILTER (WHERE status = 'open') as open,
COUNT(*) FILTER (WHERE status = 'resolved') as resolved
FROM vulnerabilities
WHERE project_id = $1
`, projectID).Scan(&vulnMetrics.Total, &vulnMetrics.Critical, &vulnMetrics.High,
&vulnMetrics.Medium, &vulnMetrics.Low, &vulnMetrics.Open, &vulnMetrics.Resolved)
q := sqlcdb.New(sh.db.DB)
mrow, err := q.GetVulnerabilityMetrics(context.Background(), uuid.MustParse(projectID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerability metrics"})
return
}
vulnMetrics.Total = int(mrow.Total)
vulnMetrics.Critical = int(mrow.Critical)
vulnMetrics.High = int(mrow.High)
vulnMetrics.Medium = int(mrow.Medium)
vulnMetrics.Low = int(mrow.Low)
vulnMetrics.Open = int(mrow.Open)
vulnMetrics.Resolved = int(mrow.Resolved)
// Get latest scan
var latestScan struct {
@@ -411,13 +384,11 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
Status string `json:"status"`
}
err = sh.db.QueryRow(`
SELECT id, COALESCE((summary->>'score')::int, 0), started_at as scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
srow, err := q.GetLatestSecurityScan(context.Background(), uuid.MustParse(projectID))
latestScan.ID = srow.ID.String()
latestScan.Score = int(srow.Score)
latestScan.ScannedAt = srow.ScannedAt
latestScan.Status = srow.Status
if err == sql.ErrNoRows {
latestScan = struct {
@@ -438,13 +409,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
LastAssessed *time.Time `json:"last_assessed"`
}
err = sh.db.QueryRow(`
SELECT overall_status, score, assessment_date
FROM compliance_reports
WHERE project_id = $1
ORDER BY assessment_date DESC
LIMIT 1
`, projectID).Scan(&complianceStatus.OverallStatus, &complianceStatus.Score, &complianceStatus.LastAssessed)
crow, err := q.GetLatestComplianceReport(context.Background(), uuid.MustParse(projectID))
complianceStatus.OverallStatus = crow.OverallStatus
complianceStatus.Score = int(crow.Score)
complianceStatus.LastAssessed = &crow.AssessmentDate
if err == sql.ErrNoRows {
complianceStatus = struct {
@@ -614,21 +582,11 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
return "", false
}
var hasAccess bool
err := sh.db.QueryRow(
`SELECT EXISTS (
SELECT 1
FROM projects p
WHERE p.id = $1
AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)`,
projectID, optionalUserUUID(c), contextIsAdmin(c),
).Scan(&hasAccess)
hasAccess, err := sqlcdb.New(sh.db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
ID: uuid.MustParse(projectID),
OwnerID: optionalUserUUID(c),
Column3: contextIsAdmin(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
return "", false
@@ -649,7 +607,8 @@ func (sh *SecurityHandler) requireSecurityScanAccess(c *gin.Context, scanID stri
}
var projectID string
err := sh.db.QueryRow("SELECT project_id FROM security_scans WHERE id = $1", scanID).Scan(&projectID)
pid, err := sqlcdb.New(sh.db.DB).GetSecurityScanProjectID(context.Background(), uuid.MustParse(scanID))
projectID = pid.String()
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"})
return false
@@ -670,7 +629,8 @@ func (sh *SecurityHandler) requireComplianceReportAccess(c *gin.Context, reportI
}
var projectID string
err := sh.db.QueryRow("SELECT project_id FROM compliance_reports WHERE id = $1", reportID).Scan(&projectID)
pid, err := sqlcdb.New(sh.db.DB).GetComplianceReportProjectID(context.Background(), uuid.MustParse(reportID))
projectID = pid.String()
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"})
return false
@@ -691,7 +651,8 @@ func (sh *SecurityHandler) requireVulnerabilityAccess(c *gin.Context, vulnID str
}
var projectID string
err := sh.db.QueryRow("SELECT project_id FROM vulnerabilities WHERE id = $1", vulnID).Scan(&projectID)
pid, err := sqlcdb.New(sh.db.DB).GetVulnerabilityProjectID(context.Background(), uuid.MustParse(vulnID))
projectID = pid.String()
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Vulnerability not found"})
return "", false
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
)
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL). Covers the generated auth/security queries: user upsert
// conflict path, project-access EXISTS predicate, vulnerability ordering and
// status update, latest-scan/report lookups, and the framework list.
func TestSecurityLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
otherID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
scanID := uuid.New()
reportID := uuid.New()
frameworkID := uuid.New()
vulnCrit := uuid.New()
vulnLow := uuid.New()
suffix := strings.ReplaceAll(userID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test'), ($3, $4, 'x', 'other')`,
userID, "sec-"+suffix+"@test.local", otherID, "sec-"+suffix+"[email protected]")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, false)`,
projectID, "sec-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
VALUES ($1, $2, $3, $4, 'web', 'image')`,
serviceID, "sec-"+suffix, projectID, envID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id IN ($1, $2)`, userID, otherID) }()
// Auth queries: upsert conflicts update rather than error, counts see rows.
_, err = q.InsertUserAdmin(ctx, sqlcdb.InsertUserAdminParams{
Email: "ins-" + suffix + "@test.local", PasswordHash: "x", Name: "ins", IsAdmin: false,
})
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE email = $1`, "ins-"+suffix+"@test.local") }()
urow, err := q.UpsertLocalUser(ctx, sqlcdb.UpsertLocalUserParams{
Email: "ins-" + suffix + "@test.local", PasswordHash: "y", Name: "renamed",
Column4: "http://img", IsAdmin: false,
})
must(err)
if urow.Name != "renamed" || urow.AvatarUrl != "http://img" {
t.Fatalf("upsert: %+v", urow)
}
byEmail, err := q.GetUserByEmailForAuth(ctx, "ins-"+suffix+"@test.local")
must(err)
if byEmail.ID != urow.ID || byEmail.PasswordHash != "x" {
t.Fatalf("by email: %+v", byEmail)
}
count, err := q.CountUsersByEmail(ctx, "ins-"+suffix+"@test.local")
must(err)
if count != 1 {
t.Fatalf("count by email: %d", count)
}
byID, err := q.GetUserByID(ctx, urow.ID)
must(err)
if byID.Email != "ins-"+suffix+"@test.local" {
t.Fatalf("by id: %+v", byID)
}
// Access predicate: unapproved project is owner/admin/member only.
ownerAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{
ID: projectID, OwnerID: userID, Column3: false,
})
must(err)
if !ownerAccess {
t.Fatal("owner should have access")
}
strangerAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{
ID: projectID, OwnerID: otherID, Column3: false,
})
must(err)
if strangerAccess {
t.Fatal("stranger should not have access")
}
adminAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{
ID: projectID, OwnerID: otherID, Column3: true,
})
must(err)
if !adminAccess {
t.Fatal("admin should have access")
}
ownerText, err := q.GetProjectOwnerText(ctx, projectID)
must(err)
if ownerText != userID.String() {
t.Fatalf("owner text: %q", ownerText)
}
exists, err := q.ServiceExistsInProject(ctx, sqlcdb.ServiceExistsInProjectParams{
ID: serviceID, ProjectID: projectID,
})
must(err)
if !exists {
t.Fatal("service should exist in project")
}
// Scan/report/vuln rows via raw inserts (fixtures), reads via sqlc.
_, err = db.Exec(`INSERT INTO security_scans (id, project_id, service_id, scan_type, status, summary, started_at)
VALUES ($1, $2, $3, 'dependency', 'completed', '{"score": 85}', NOW() - interval '1 hour')`,
scanID, projectID, serviceID)
must(err)
_, err = db.Exec(`INSERT INTO compliance_frameworks (id, name, description, version, enabled)
VALUES ($1, $2, 'desc', '1.0', true)`,
frameworkID, "fw-"+suffix)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM compliance_frameworks WHERE id = $1`, frameworkID) }()
_, err = db.Exec(`INSERT INTO compliance_reports (id, project_id, framework_id, overall_status, score, assessment_date)
VALUES ($1, $2, $3, 'compliant', 92, NOW() - interval '30 minutes')`,
reportID, projectID, frameworkID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM compliance_reports WHERE id = $1`, reportID) }()
_, err = db.Exec(`INSERT INTO vulnerabilities (id, type, severity, title, description, service_id, project_id, status, found_at)
VALUES ($1, 'dependency', 'low', 'low-v', 'd', $3, $4, 'open', NOW() - interval '2 hours'),
($2, 'dependency', 'critical', 'crit-v', 'd', $3, $4, 'open', NOW() - interval '1 hour')`,
vulnLow, vulnCrit, serviceID, projectID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM vulnerabilities WHERE project_id = $1`, projectID) }()
defer func() { _, _ = db.Exec(`DELETE FROM security_scans WHERE id = $1`, scanID) }()
// Severity ordering: critical first.
vulns, err := q.ListVulnerabilities(ctx, projectID)
must(err)
if len(vulns) != 2 || vulns[0].ID != vulnCrit {
t.Fatalf("vuln ordering: %+v", vulns)
}
metrics, err := q.GetVulnerabilityMetrics(ctx, projectID)
must(err)
if metrics.Total != 2 || metrics.Critical != 1 || metrics.Low != 1 || metrics.Open != 2 {
t.Fatalf("metrics: %+v", metrics)
}
must(q.UpdateVulnerabilityStatus(ctx, sqlcdb.UpdateVulnerabilityStatusParams{
Status: "resolved",
ResolvedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: vulnCrit,
}))
metrics, err = q.GetVulnerabilityMetrics(ctx, projectID)
must(err)
if metrics.Open != 1 || metrics.Resolved != 1 {
t.Fatalf("metrics after resolve: %+v", metrics)
}
// Latest scan/report lookups.
scan, err := q.GetLatestSecurityScan(ctx, projectID)
must(err)
if scan.ID != scanID || scan.Score != 85 || scan.Status != "completed" {
t.Fatalf("latest scan: %+v", scan)
}
report, err := q.GetLatestComplianceReport(ctx, projectID)
must(err)
if report.OverallStatus != "compliant" || report.Score != 92 {
t.Fatalf("latest report: %+v", report)
}
// Project-ID lookups used by the access guards.
scanPID, err := q.GetSecurityScanProjectID(ctx, scanID)
must(err)
reportPID, err := q.GetComplianceReportProjectID(ctx, reportID)
must(err)
vulnPID, err := q.GetVulnerabilityProjectID(ctx, vulnCrit)
must(err)
if scanPID != projectID || reportPID != projectID || vulnPID != projectID {
t.Fatalf("project id lookups: %v %v %v", scanPID, reportPID, vulnPID)
}
// Framework list only returns enabled rows.
frameworks, err := q.ListComplianceFrameworks(ctx)
must(err)
found := false
for _, f := range frameworks {
if f.ID == frameworkID && f.Enabled {
found = true
}
}
if !found {
t.Fatal("enabled framework missing from list")
}
frameworkExists, err := q.ComplianceFrameworkExists(ctx, frameworkID)
must(err)
if !frameworkExists {
t.Fatal("framework should exist")
}
}
+78 -78
View File
@@ -1,11 +1,14 @@
package api
import (
"context"
"database/sql"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -53,11 +56,8 @@ func handleCloneService(c *gin.Context) {
if name == "" {
name = source.Name + "-copy"
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
targetProjectID, name,
).Scan(&count)
count, _ := sqlcdb.New(db.(*database.DB).DB).CountServicesByName(
c.Request.Context(), sqlcdb.CountServicesByNameParams{ProjectID: targetProjectID, Name: name})
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
return
@@ -67,68 +67,74 @@ func handleCloneService(c *gin.Context) {
if req.Environment != "" {
environment = req.Environment
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
newID := uuid.New()
now := time.Now()
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
_, err = db.(*database.DB).Exec(
`INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
build_command, start_command, type, 'stopped', image, command, $5,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
$9, $9
FROM services WHERE id = $10`,
newID, targetProjectID, name, environmentID, environment, volumesJSON,
maintenance, basicAuth, now, source.ID,
)
newID, err := cloneServiceRow(db.(*database.DB), source.ID, targetProjectID, name, environment)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
return
}
if _, err := db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
return
}
// Variable values are copied as stored — ciphertext stays ciphertext.
if _, err := db.(*database.DB).Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
return
}
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
}
// cloneServiceRow copies a service's full configuration — source, build
// settings, volumes, access gates, domains, and variables — under a new id
// in the target project. The clone starts stopped. An empty environment
// inherits the source's. Used by the clone endpoint and preview
// environments, which additionally override git_branch and domains.
func cloneServiceRow(db *database.DB, sourceID uuid.UUID, targetProjectID uuid.UUID, name, environment string) (uuid.UUID, error) {
ctx := context.Background()
q := sqlcdb.New(db.DB)
sourceEnv, err := q.GetServiceEnvironment(ctx, sourceID)
if err != nil {
return uuid.Nil, err
}
if environment == "" {
environment = sourceEnv
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db, targetProjectID, environment)
if err != nil {
return uuid.Nil, err
}
maintenance, basicAuth := serviceAccess(db, sourceID)
newID := uuid.New()
now := time.Now()
err = q.CloneServiceRow(ctx, sqlcdb.CloneServiceRowParams{
ID: newID,
ProjectID: targetProjectID,
Name: name,
EnvironmentID: environmentID,
Environment: sql.NullString{String: environment, Valid: true},
MaintenanceMode: maintenance,
BasicAuthUsers: basicAuth,
CreatedAt: sql.NullTime{Time: now, Valid: true},
ID_2: sourceID,
})
if err != nil {
return uuid.Nil, err
}
if err := q.CloneServiceDomains(ctx, sqlcdb.CloneServiceDomainsParams{
ServiceID: newID, ServiceID_2: sourceID}); err != nil {
_ = q.DeleteServiceByID(ctx, newID)
return uuid.Nil, err
}
// Variable values are copied as stored — ciphertext stays ciphertext.
if err := q.CloneServiceVariables(ctx, sqlcdb.CloneServiceVariablesParams{
ServiceID: newID, ServiceID_2: sourceID}); err != nil {
_ = q.DeleteServiceByID(ctx, newID)
return uuid.Nil, err
}
return newID, nil
}
type MoveServiceRequest struct {
ProjectID uuid.UUID `json:"project_id" binding:"required"`
}
@@ -169,11 +175,8 @@ func handleMoveService(c *gin.Context) {
return
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
req.ProjectID, service.Name,
).Scan(&count)
count, _ := sqlcdb.New(db.(*database.DB).DB).CountServicesByName(
c.Request.Context(), sqlcdb.CountServicesByNameParams{ProjectID: req.ProjectID, Name: service.Name})
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
return
@@ -189,15 +192,18 @@ func handleMoveService(c *gin.Context) {
return
}
res, err := db.(*database.DB).Exec(
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
req.ProjectID, environmentID, time.Now(), serviceID,
)
n, err := sqlcdb.New(db.(*database.DB).DB).MoveServiceProject(
c.Request.Context(), sqlcdb.MoveServiceProjectParams{
ProjectID: req.ProjectID,
EnvironmentID: environmentID,
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: serviceID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
return
}
@@ -211,16 +217,10 @@ func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bo
if isAdmin {
return true
}
var owner string
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
return err == nil && owner == userID
}
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
return []byte("[]")
uid, err := uuid.Parse(userID)
if err != nil {
return false
}
return raw
owner, err := sqlcdb.New(db.DB).GetProjectOwner(context.Background(), projectID)
return err == nil && owner == uid
}
+130 -33
View File
@@ -2,6 +2,7 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
@@ -9,6 +10,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
@@ -58,28 +60,29 @@ type Service struct {
Spread bool `json:"spread" db:"-"`
// PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. An explicit pin overrides them — pin + tags is rejected.
PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
// TraefikLabels holds per-service middleware overrides —
// "middlewares.<name>.<type>.<field>" → value. Lazy-read.
TraefikLabels map[string]string `json:"traefik_labels,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// loadServiceSleep fills the sleep-mode columns, which live outside the
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
func loadServiceSleep(db *database.DB, s *Service) {
var idle int
if err := db.QueryRow(
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
s.ID,
).Scan(&s.SleepEnabled, &idle); err == nil {
s.SleepIdleMinutes = idle
row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID)
if err == nil {
s.SleepEnabled = row.SleepEnabled
s.SleepIdleMinutes = int(row.SleepIdleMinutes)
}
}
// serviceNodeID reads the pin column directly — kept out of the wide service
// SELECTs so pre-migration schemas keep working.
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
var id sql.NullString
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID)
if err == nil {
return id.String
}
return ""
@@ -87,17 +90,14 @@ func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
// serviceSpread reads the spread column with the same lazy pattern.
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
var spread bool
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil {
return spread
}
return false
spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID)
return err == nil && spread
}
// servicePlacementTags reads the placement_tags column with the lazy pattern.
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
var raw []byte
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID)
if err != nil {
return nil
}
var tags []string
@@ -123,6 +123,78 @@ func normalizePlacementTags(tags []string) []string {
return out
}
// traefikLabelKey matches "middlewares.<name>.<type>" with an optional
// ".<subkey>" tail — e.g. "middlewares.rl.ratelimit.average".
var traefikLabelKey = regexp.MustCompile(`^middlewares\.([A-Za-z0-9][A-Za-z0-9_-]{0,39})\.([A-Za-z]+)(\.[A-Za-z0-9_.*+-]+)?$`)
// traefikMiddlewareTypes are the Traefik middleware kinds a service may
// define. Router/service/provider-level keys are never accepted — those
// would let a tenant hijack someone else's routing.
var traefikMiddlewareTypes = map[string]bool{
"addprefix": true, "basicauth": true, "buffering": true, "chain": true,
"circuitbreaker": true, "compress": true, "digestauth": true,
"errors": true, "forwardauth": true, "grpcweb": true, "headers": true,
"inflightreq": true, "ipallowlist": true, "ipwhitelist": true,
"passtlsclientcert": true, "ratelimit": true, "redirectregex": true,
"redirectscheme": true, "replacepath": true, "replacepathregex": true,
"retry": true, "stripprefix": true, "stripprefixregex": true,
}
// normalizeTraefikLabels validates a raw override map. Returns the cleaned
// map (middleware type lowercased — Traefik label keys are canonical-lower)
// or an error naming the offending key.
func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
if len(raw) > 32 {
return nil, fmt.Errorf("traefik_labels: at most 32 entries (got %d)", len(raw))
}
out := make(map[string]string, len(raw))
names := map[string]bool{}
for k, v := range raw {
m := traefikLabelKey.FindStringSubmatch(k)
if m == nil {
return nil, fmt.Errorf("traefik_labels: invalid key %q — want middlewares.<name>.<type>[.<field>]", k)
}
mwType := strings.ToLower(m[2])
if !traefikMiddlewareTypes[mwType] {
return nil, fmt.Errorf("traefik_labels: unsupported middleware type %q", m[2])
}
if len(v) == 0 {
return nil, fmt.Errorf("traefik_labels: %q has an empty value", k)
}
if len(v) > 512 || strings.ContainsAny(v, "\r\n") {
return nil, fmt.Errorf("traefik_labels: %q value must be a single line under 512 chars", k)
}
out["middlewares."+m[1]+"."+mwType+m[3]] = v
names[m[1]] = true
}
if len(names) > 16 {
return nil, fmt.Errorf("traefik_labels: at most 16 middleware names")
}
return out, nil
}
// serviceTraefikLabels lazy-reads the traefik_labels column.
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID)
if err != nil {
return nil
}
var labels map[string]string
if err := json.Unmarshal(raw, &labels); err != nil || len(labels) == 0 {
return nil
}
return labels
}
// traefikLabelsJSON marshals an override map for the jsonb column.
func traefikLabelsJSON(labels map[string]string) []byte {
if len(labels) == 0 {
return []byte("{}")
}
raw, _ := json.Marshal(labels)
return raw
}
// tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null.
func tagsJSON(tags []string) []byte {
if len(tags) == 0 {
@@ -135,15 +207,15 @@ func tagsJSON(tags []string) []byte {
// loadServiceNode fills the pin + spread + placement tags + the agent's
// display name for responses.
func loadServiceNode(db *database.DB, s *Service) {
var name sql.NullString
s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID)
s.TraefikLabels = serviceTraefikLabels(db, s.ID)
if s.NodeID == "" {
return
}
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
s.NodeName = name.String
if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil {
s.NodeName = name
}
}
@@ -158,8 +230,8 @@ type ServiceVolume struct {
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID)
if err != nil {
return nil
}
var stored []ServiceVolume
@@ -237,6 +309,10 @@ type CreateServiceRequest struct {
// PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. Mutually exclusive with an explicit node pin.
PlacementTags []string `json:"placement_tags"`
// TraefikLabels defines extra Traefik middlewares for this service's
// router — keys "middlewares.<name>.<type>[.<field>]", validated against
// an allowlist. Defined middlewares attach to the router automatically.
TraefikLabels map[string]string `json:"traefik_labels"`
}
// UpdateServiceRequest represents a request to update a service
@@ -275,6 +351,9 @@ type UpdateServiceRequest struct {
// listed tags; pointer so [] clears the requirement. Mutually exclusive
// with an explicit node pin.
PlacementTags *[]string `json:"placement_tags"`
// TraefikLabels replaces the service's middleware overrides; pointer so
// {} clears them and nil leaves them untouched.
TraefikLabels *map[string]string `json:"traefik_labels"`
}
// handleGetServices retrieves all services for a project
@@ -478,6 +557,14 @@ func handleCreateService(c *gin.Context) {
if service.Builder == "static" && service.StaticDir == "" {
service.StaticDir = "dist"
}
if len(req.TraefikLabels) > 0 {
labels, err := normalizeTraefikLabels(req.TraefikLabels)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
service.TraefikLabels = labels
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -557,9 +644,9 @@ func handleCreateService(c *gin.Context) {
healthcheck_path, restart_policy, volumes,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes,
created_at, updated_at)
traefik_labels, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38)`,
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command,
@@ -572,6 +659,7 @@ func handleCreateService(c *gin.Context) {
tagsJSON(service.PlacementTags),
service.SleepEnabled,
sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0},
traefikLabelsJSON(service.TraefikLabels),
service.CreatedAt, service.UpdatedAt,
)
@@ -610,13 +698,11 @@ func handleCreateService(c *gin.Context) {
}
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
var environmentID uuid.UUID
err := db.QueryRow(
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
projectID,
environment,
).Scan(&environmentID)
return environmentID, err
row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID,
Name: environment,
})
return row.ID, err
}
func inferServiceSourceType(service Service) string {
@@ -848,6 +934,16 @@ func handleUpdateService(c *gin.Context) {
}
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
}
if req.TraefikLabels != nil {
labels, err := normalizeTraefikLabels(*req.TraefikLabels)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
existingService.TraefikLabels = labels
} else {
existingService.TraefikLabels = serviceTraefikLabels(db.(*database.DB), serviceID)
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -924,7 +1020,7 @@ func handleUpdateService(c *gin.Context) {
builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23,
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27,
placement_tags = $28::jsonb
placement_tags = $28::jsonb, traefik_labels = $29::jsonb
WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
@@ -938,6 +1034,7 @@ func handleUpdateService(c *gin.Context) {
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
existingService.Spread,
tagsJSON(existingService.PlacementTags),
traefikLabelsJSON(existingService.TraefikLabels),
)
if err != nil {
@@ -0,0 +1,64 @@
package api
import (
"strings"
"testing"
)
func TestNormalizeTraefikLabels(t *testing.T) {
valid := map[string]string{
"middlewares.rl.ratelimit.average": "100",
"middlewares.rl.ratelimit.period": "1m",
"middlewares.sec.headers.customrequestheaders.X-Frame-Options": "DENY",
"middlewares.gzip.compress": "true",
}
out, err := normalizeTraefikLabels(valid)
if err != nil {
t.Fatalf("valid labels rejected: %v", err)
}
if len(out) != len(valid) {
t.Fatalf("expected %d labels, got %d", len(valid), len(out))
}
if out["middlewares.gzip.compress"] != "true" {
t.Fatalf("flag-style middleware missing: %v", out)
}
}
func TestNormalizeTraefikLabelsLowercasesType(t *testing.T) {
out, err := normalizeTraefikLabels(map[string]string{
"middlewares.rl.RateLimit.average": "10",
})
if err != nil {
t.Fatalf("mixed-case type rejected: %v", err)
}
if out["middlewares.rl.ratelimit.average"] != "10" {
t.Fatalf("type not lowercased: %v", out)
}
}
func TestNormalizeTraefikLabelsRejects(t *testing.T) {
cases := map[string]string{
"traefik.enable": "true", // provider-level
"middlewares..ratelimit.average": "1", // empty name
"middlewares.x.evilinject.thing": "1", // unknown type
"routers.svc.rule": "x", // router hijack
"middlewares.x.ratelimit.average.bad\ninjection": "1", // control char in key fails regex
}
for k, v := range cases {
if _, err := normalizeTraefikLabels(map[string]string{k: v}); err == nil {
t.Fatalf("key %q should have been rejected", k)
}
}
}
func TestNormalizeTraefikLabelsRejectsBadValues(t *testing.T) {
if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": ""}); err == nil {
t.Fatal("empty value accepted")
}
if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": strings.Repeat("x", 600)}); err == nil {
t.Fatal("oversized value accepted")
}
if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": "1\nmalicious"}); err == nil {
t.Fatal("newline value accepted")
}
}
+14 -16
View File
@@ -10,11 +10,13 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Platform setting keys. Values stored in app_settings take precedence over
@@ -70,8 +72,7 @@ func handleGetBranding(c *gin.Context) {
// then the caller's default.
func settingValue(db *database.DB, key, envKey, fallback string) string {
if db != nil && db.DB != nil {
var value string
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key)
if err == nil {
return value
}
@@ -88,18 +89,15 @@ func settingValue(db *database.DB, key, envKey, fallback string) string {
}
func setSetting(db *database.DB, key, value string, secret bool) error {
_, err := db.Exec(`
INSERT INTO app_settings (key, value, is_secret, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
`, key, value, secret)
return err
return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{
Key: key,
Value: value,
IsSecret: secret,
})
}
func deleteSetting(db *database.DB, key string) error {
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
return err
return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key)
}
// signupEnabled controls whether public registration stays open after the
@@ -110,11 +108,12 @@ func signupEnabled(db *database.DB) bool {
}
func isAdminUser(db *database.DB, userID string) bool {
var isAdmin bool
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
parsed, err := uuid.Parse(userID)
if err != nil {
return false
}
return isAdmin
isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed)
return err == nil && isAdmin
}
func requireAdmin(c *gin.Context) (*database.DB, bool) {
@@ -242,8 +241,7 @@ func handleUpdateSettings(c *gin.Context) {
// resolvedTunnelToken prefers the in-app value and falls back to the env.
func resolvedTunnelToken(db *database.DB) (string, string) {
if db != nil && db.DB != nil {
var value string
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value), "app"
}
}
+79
View File
@@ -0,0 +1,79 @@
package api
// First-run setup wizard state. GET /setup/status is public so the web
// shell can redirect fresh installs before a session exists; the flag
// lives in app_settings so it survives restarts.
import (
"context"
"net/http"
"strings"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
)
const settingSetupCompleted = "setup_completed"
// setupComplete resolves the flag. Installs that predate the wizard
// (users + projects already exist) auto-complete — nobody who has been
// running Containr for months should get a welcome screen.
func setupComplete(db *database.DB, hasUsers bool) bool {
if strings.EqualFold(settingValue(db, settingSetupCompleted, "", ""), "true") {
return true
}
if !hasUsers {
return false
}
if projects, err := sqlcdb.New(db.DB).CountProjects(context.Background()); err == nil && projects > 0 {
return true
}
return false
}
// GET /api/v1/setup/status — public: fresh installs hit it before any
// session exists.
func handleSetupStatus(c *gin.Context) {
db, ok := c.MustGet("db").(*database.DB)
if !ok {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
return
}
count, err := countLocalUsers(db)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to inspect setup state")
return
}
hasUsers := count > 0
completed := setupComplete(db, hasUsers)
tunnelToken, _ := resolvedTunnelToken(db)
c.JSON(http.StatusOK, gin.H{
"has_users": hasUsers,
"setup_completed": completed,
"needs_setup": !completed,
"has_github_app": githubAppConfigured(db),
"has_tunnel": tunnelToken != "",
"signup_enabled": signupEnabled(db),
})
}
// POST /api/v1/setup/complete — marks the wizard done. Open to any
// authenticated user: the first account is the owner, and the flag only
// suppresses a banner.
func handleSetupComplete(c *gin.Context) {
if _, ok := requireAuthenticatedUserID(c); !ok {
return
}
db, ok := c.MustGet("db").(*database.DB)
if !ok {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
return
}
if err := setSetting(db, settingSetupCompleted, "true", false); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to save setup state")
return
}
c.JSON(http.StatusOK, gin.H{"message": "Setup completed"})
}
+18 -46
View File
@@ -86,20 +86,17 @@ type sleepCandidate struct {
}
func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
rows, err := db.QueryContext(ctx,
`SELECT id, project_id, name, COALESCE(status,''), COALESCE(sleep_idle_minutes,15)
FROM services WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')`)
sweepRows, err := sqlcdb.New(db.DB).ListSleepCandidates(ctx)
if err != nil {
return
}
var candidates []sleepCandidate
for rows.Next() {
var s sleepCandidate
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.Status, &s.IdleMinutes); err == nil {
candidates = append(candidates, s)
}
for _, r := range sweepRows {
candidates = append(candidates, sleepCandidate{
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name,
Status: r.Status, IdleMinutes: int(r.SleepIdleMinutes),
})
}
rows.Close()
q := sqlcdb.New(db.DB)
for _, svc := range candidates {
@@ -187,11 +184,10 @@ func putToSleepRemote(ctx context.Context, db *database.DB, q *sqlcdb.Queries, s
tracker.mu.Lock()
delete(tracker.activity, svc.ID)
tracker.mu.Unlock()
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
setServiceStatusNow(db, svc.ID, "sleeping")
log.Printf("sleep: %s (%s) remote replicas removed after idle timeout", svc.Name, svc.ID)
var domain string
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
domain, _ := q.GetServiceDomainText(ctx, svc.ID)
domains := serviceDomainNames(db, svc.ID, domain)
if len(domains) > 0 {
writeWakeIngress(svc.ID.String(), domains, apiPort)
@@ -250,11 +246,10 @@ func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine
tracker.mu.Lock()
delete(tracker.activity, svc.ID)
tracker.mu.Unlock()
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
setServiceStatusNow(db, svc.ID, "sleeping")
log.Printf("sleep: %s (%s) scaled to zero after idle timeout", svc.Name, svc.ID)
var domain string
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
domain, _ := sqlcdb.New(db.DB).GetServiceDomainText(ctx, svc.ID)
domains := serviceDomainNames(db, svc.ID, domain)
if len(domains) == 0 {
return
@@ -444,7 +439,9 @@ func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client
}
}
var status string
_ = db.QueryRow(`SELECT COALESCE(status,'') FROM services WHERE id = $1`, svcID).Scan(&status)
if sid, err := uuid.Parse(svcID); err == nil {
status, _ = sqlcdb.New(db.DB).GetServiceStatusText(ctx, sid)
}
if live > 0 || status == "stopped" || status == "" {
_ = dc.RemoveContainer(ctx, c.ID, true)
}
@@ -491,34 +488,11 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
if err != nil {
return http.StatusBadRequest, gin.H{"error": "invalid service id"}
}
var service Service
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at
FROM services s WHERE s.id = $1`, id,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), id)
if err != nil {
return http.StatusNotFound, gin.H{"error": "service not found"}
}
service := serviceFromRuntimeRow(row)
if service.Status == "running" || service.Status == "deployed" {
return http.StatusOK, gin.H{"status": service.Status}
}
@@ -528,13 +502,13 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
log.Printf("wake: runtime spec for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, "failed")
return
}
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
log.Printf("wake: reconcile for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, "failed")
return
}
status := "running"
@@ -542,7 +516,7 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
status = state.Status
persistPublishedPort(db, service.ID, state.Ports)
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
setServiceStatusNow(db, service.ID, status)
}()
return http.StatusAccepted, gin.H{"status": "waking"}
}
@@ -563,8 +537,6 @@ func handleServiceSleep(c *gin.Context) {
if !ok {
return
}
var port int
_ = db.QueryRow(`SELECT COALESCE(port,0) FROM services WHERE id = $1`, service.ID).Scan(&port)
svc := sleepCandidate{
ID: service.ID,
ProjectID: service.ProjectID,
+33 -18
View File
@@ -640,19 +640,20 @@ func deployTemplateGraph(
createdServiceIDs := []uuid.UUID{}
createdDatabaseIDs := []string{}
cleanup := func() {
q := sqlcdb.New(db.DB)
for _, sid := range createdServiceIDs {
if engine != nil {
removeServiceRuntime(context.Background(), db, engine, sid)
}
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid)
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid)
_ = q.DeleteEnvVarsForService(context.Background(), sid)
_ = q.DeleteServiceByID(context.Background(), sid)
}
for _, did := range createdDatabaseIDs {
if dbHandler != nil && dbHandler.dockerClient != nil {
_ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true)
_ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true)
}
_, _ = db.Exec(`DELETE FROM database_services WHERE id = $1`, did)
_ = q.DeleteDatabaseServiceByID(context.Background(), did)
}
}
@@ -772,14 +773,19 @@ func deployTemplateGraph(
if builder == "" {
builder = "auto"
}
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2, volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4, ''), git_branch = NULLIF($5, ''), builder = $6
WHERE id = $7`,
m.spec.Port, strings.TrimSpace(m.spec.HealthCheck), volumesJSON,
strings.TrimSpace(m.spec.Repo), strings.TrimSpace(m.spec.Branch), builder,
serviceID,
); err != nil {
var volumesRaw json.RawMessage
if s, ok := volumesJSON.(string); ok {
volumesRaw = json.RawMessage(s)
}
if err = sqlcdb.New(tx).UpdateServiceDeployMeta(ctx, sqlcdb.UpdateServiceDeployMetaParams{
Port: int32(m.spec.Port),
HealthcheckPath: strings.TrimSpace(m.spec.HealthCheck),
Column3: volumesRaw,
Column4: strings.TrimSpace(m.spec.Repo),
Column5: strings.TrimSpace(m.spec.Branch),
Builder: builder,
ID: serviceID,
}); err != nil {
tx.Rollback()
cleanup()
return nil, &gin.H{"error": "Failed to configure service"}
@@ -861,15 +867,24 @@ func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment
CreatedAt: now,
UpdatedAt: now,
}
if _, err := db.Exec(
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, NULL, $4, '', '', $5, $6)`,
dep.ID, dep.ServiceID, fmt.Sprintf("v%d", now.Unix()), dep.Status, dep.CreatedAt, dep.UpdatedAt,
); err != nil {
q := sqlcdb.New(db.DB)
if err := q.InsertDeployment(context.Background(), sqlcdb.InsertDeploymentParams{
ID: dep.ID,
ServiceID: dep.ServiceID,
Version: fmt.Sprintf("v%d", now.Unix()),
Status: sql.NullString{String: dep.Status, Valid: true},
ImageName: sql.NullString{String: "", Valid: true},
ImageTag: sql.NullString{String: "", Valid: true},
CreatedAt: sql.NullTime{Time: dep.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: dep.UpdatedAt, Valid: true},
}); err != nil {
return uuid.Nil
}
_, _ = db.Exec(`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
_ = q.SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: service.ID,
})
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: dep.ID,
+5 -6
View File
@@ -411,12 +411,11 @@ func handleCreateFromTemplate(c *gin.Context) {
return
}
// sqlc's schema predates the runtime columns; port and healthcheck_path are
// written via raw SQL like every other service mutation path.
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
if err = sqlcdb.New(tx).UpdateServiceRuntimeMeta(ctx, sqlcdb.UpdateServiceRuntimeMetaParams{
Port: int32(config.Port),
HealthcheckPath: strings.TrimSpace(config.HealthCheck),
ID: serviceID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
+16 -9
View File
@@ -1,11 +1,14 @@
package api
import (
"containr/internal/database"
"containr/internal/secrets"
"database/sql"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -147,7 +150,8 @@ func handleUpdateVariables(c *gin.Context) {
}
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
txq := sqlcdb.New(tx)
err = txq.DeleteEnvVarsForService(c.Request.Context(), serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
@@ -164,12 +168,15 @@ func handleUpdateVariables(c *gin.Context) {
if v.IsSecret {
value = secrets.Encrypt(value)
}
varID := uuid.New()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
varID, serviceID, v.Key, value, v.IsSecret, now, now,
)
err = txq.InsertEnvVar(c.Request.Context(), sqlcdb.InsertEnvVarParams{
ID: uuid.New(),
ServiceID: serviceID,
Key: v.Key,
Value: value,
IsSecret: sql.NullBool{Bool: v.IsSecret, Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
+30
View File
@@ -31,3 +31,33 @@ func TestWebhookEventMatches(t *testing.T) {
}
}
}
func TestValidWebhookURL(t *testing.T) {
cases := []struct {
name string
url string
wantErr bool
}{
{"https", "https://hooks.example.com/wh/abc", false},
{"http", "http://internal.lan:8080/notify", false},
{"whitespace trimmed", " https://example.com/hook ", false},
{"with query", "https://example.com/hook?token=x", false},
{"ftp rejected", "ftp://files.example.com/x", true},
{"no scheme", "example.com/hook", true},
{"bare scheme", "https://", true},
{"empty", "", true},
{"javascript rejected", "javascript:alert(1)", true},
{"host empty", "http:///path-only", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validWebhookURL(tc.url)
if tc.wantErr && err == nil {
t.Errorf("validWebhookURL(%q) = nil, want error", tc.url)
}
if !tc.wantErr && err != nil {
t.Errorf("validWebhookURL(%q) = %v, want nil", tc.url, err)
}
})
}
}
@@ -0,0 +1,97 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// EnvironmentsCmd manages a project's environment lanes
// (production/development/preview are seeded; custom names allowed).
var EnvironmentsCmd = &cobra.Command{
Use: "environments",
Aliases: []string{"env", "envs"},
Short: "Manage project environments",
}
var envListCmd = &cobra.Command{
Use: "list",
Short: "List environments in a project",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
project, _ := cmd.Flags().GetString("project")
if project == "" {
return fmt.Errorf("--project is required")
}
data, err := c.Do("GET", "/projects/"+project+"/environments", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "environments")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, e := range items {
rows = append(rows, []string{
str(e, "id"), str(e, "name"), str(e, "service_count"), relTime(e, "created_at"),
})
}
printRows(data, []string{"ID", "NAME", "SERVICES", "CREATED"}, rows)
return nil
},
}
var envCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create an environment in a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
project, _ := cmd.Flags().GetString("project")
if project == "" {
return fmt.Errorf("--project is required")
}
data, err := c.Do("POST", "/projects/"+project+"/environments", map[string]interface{}{
"name": args[0],
})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var envDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete an empty environment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Delete environment " + args[0] + "?") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
c, err := client()
if err != nil {
return err
}
_, err = c.Do("DELETE", "/environments/"+args[0], nil)
if err != nil {
return err
}
fmt.Println("Environment deleted")
return nil
},
}
func init() {
envListCmd.Flags().String("project", "", "project ID (required)")
envCreateCmd.Flags().String("project", "", "project ID (required)")
EnvironmentsCmd.AddCommand(envListCmd, envCreateCmd, envDeleteCmd)
}
+6 -2
View File
@@ -84,7 +84,7 @@ var nodesDeleteCmd = &cobra.Command{
var nodesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a node agent (--name, --auto-prune, --tags)",
Short: "Update a node agent (--name, --auto-prune, --tags, --domain)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
@@ -108,8 +108,11 @@ var nodesUpdateCmd = &cobra.Command{
}
body["tags"] = tags
}
if cmd.Flags().Changed("domain") {
body["default_domain"], _ = cmd.Flags().GetString("domain")
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass --name, --auto-prune or --tags", ExitCode: ExitError}
return &APIError{Message: "nothing to update — pass --name, --auto-prune, --tags or --domain", ExitCode: ExitError}
}
data, err := c.Do("PUT", "/agents/"+args[0], body)
if err != nil {
@@ -304,6 +307,7 @@ func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name")
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)")
nodesUpdateCmd.Flags().String("domain", "", "base domain for this node — domainless services placed here get <name>.<domain> automatically (empty clears)")
nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
@@ -1,6 +1,8 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
@@ -60,6 +62,104 @@ var notifReadCmd = &cobra.Command{
},
}
var notifChannelsCmd = &cobra.Command{
Use: "channels",
Short: "Manage push notification channels (ntfy/Gotify)",
}
var notifChannelsListCmd = &cobra.Command{
Use: "list",
Short: "List push channels",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/notifications/channels", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "channels")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, n := range items {
rows = append(rows, []string{
str(n, "id"), str(n, "kind"), str(n, "endpoint"), str(n, "enabled"), relTime(n, "created_at"),
})
}
printRows(data, []string{"ID", "KIND", "ENDPOINT", "ENABLED", "AGE"}, rows)
return nil
},
}
var notifChannelsAddCmd = &cobra.Command{
Use: "add <ntfy|gotify> <endpoint>",
Short: "Add a push channel (endpoint includes topic for ntfy)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
token, _ := cmd.Flags().GetString("token")
data, err := c.Do("POST", "/notifications/channels", map[string]interface{}{
"kind": args[0],
"endpoint": args[1],
"token": token,
})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var notifChannelsTestCmd = &cobra.Command{
Use: "test <id>",
Short: "Send a test notification through a channel",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/notifications/channels/"+args[0]+"/test", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var notifChannelsRemoveCmd = &cobra.Command{
Use: "remove <id>",
Short: "Remove a push channel",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
if !yes && !Confirm(fmt.Sprintf("Remove channel %s?", args[0])) {
return nil
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/notifications/channels/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd)
notifChannelsAddCmd.Flags().String("token", "", "Auth token (ntfy access token or Gotify app token)")
notifChannelsRemoveCmd.Flags().Bool("yes", false, "Skip confirmation")
notifChannelsCmd.AddCommand(notifChannelsListCmd, notifChannelsAddCmd, notifChannelsTestCmd, notifChannelsRemoveCmd)
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd, notifChannelsCmd)
}
@@ -134,6 +134,11 @@ var servicesCreateCmd = &cobra.Command{
}
body["placement_tags"] = tags
}
if labels, err := traefikLabelFlag(cmd); err != nil {
return err
} else if labels != nil {
body["traefik_labels"] = labels
}
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil {
return err
@@ -148,6 +153,28 @@ var servicesCreateCmd = &cobra.Command{
},
}
// traefikLabelFlag parses --traefik-label key=value pairs into the API's
// map shape. Returns nil when the flag was never passed; an empty map when
// only empty values were given (clears overrides on update).
func traefikLabelFlag(cmd *cobra.Command) (map[string]string, error) {
if !cmd.Flags().Changed("traefik-label") {
return nil, nil
}
pairs, _ := cmd.Flags().GetStringArray("traefik-label")
labels := map[string]string{}
for _, p := range pairs {
if strings.TrimSpace(p) == "" {
continue
}
k, v, ok := strings.Cut(p, "=")
if !ok || strings.TrimSpace(k) == "" {
return nil, &APIError{Message: fmt.Sprintf("invalid --traefik-label %q — want key=value", p), ExitCode: ExitError}
}
labels[strings.TrimSpace(k)] = v
}
return labels, nil
}
var servicesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a service",
@@ -241,6 +268,11 @@ var servicesUpdateCmd = &cobra.Command{
v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v
}
if labels, err := traefikLabelFlag(cmd); err != nil {
return err
} else if labels != nil {
body["traefik_labels"] = labels
}
if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true"
@@ -533,6 +565,7 @@ func init() {
f.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
f.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)")
f.StringArray("traefik-label", nil, "Traefik middleware override, key=value (e.g. middlewares.rl.ratelimit.average=100); repeatable")
uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name")
@@ -557,6 +590,7 @@ func init() {
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
uf.StringArray("traefik-label", nil, "Traefik middleware override, key=value; repeatable; empty list clears via --traefik-label ''")
uf.String("node", "", "pin to a node agent id, 'auto' for least-loaded, 'local' to clear")
uf.Bool("spread", false, "spread replicas across all online schedulable nodes (--spread=false to disable)")
uf.String("placement-tags", "", "comma-separated node tags required for remote placement (empty clears)")
+1
View File
@@ -95,6 +95,7 @@ func init() {
rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd)
rootCmd.AddCommand(commands.MigrateCmd)
rootCmd.AddCommand(commands.EnvironmentsCmd)
}
func initConfig() {
@@ -75,7 +75,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
`
type CreateAgentParams struct {
@@ -124,6 +124,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
)
return i, err
}
@@ -258,7 +259,7 @@ func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg Delete
}
const getAgent = `-- name: GetAgent :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents WHERE id = $1
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents WHERE id = $1
`
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
@@ -281,12 +282,13 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
)
return i, err
}
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents
WHERE hostname = $1 AND ip_address = $2
LIMIT 1
`
@@ -316,6 +318,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
)
return i, err
}
@@ -523,7 +526,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
}
const listAgents = `-- name: ListAgents :many
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents ORDER BY created_at ASC
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents ORDER BY created_at ASC
`
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
@@ -552,6 +555,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
); err != nil {
return nil, err
}
@@ -695,6 +699,39 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
return items, nil
}
const listSchedulableAgentDomains = `-- name: ListSchedulableAgentDomains :many
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain
`
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
func (q *Queries) ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listSchedulableAgentDomains, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var default_domain string
if err := rows.Scan(&default_domain); err != nil {
return nil, err
}
items = append(items, default_domain)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSchedulableAgents = `-- name: ListSchedulableAgents :many
SELECT id, name FROM node_agents
WHERE status = 'online' AND schedulable
@@ -910,6 +947,20 @@ func (q *Queries) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPaylo
return err
}
const setAgentDefaultDomain = `-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1
`
type SetAgentDefaultDomainParams struct {
ID string `json:"id"`
DefaultDomain string `json:"default_domain"`
}
func (q *Queries) SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error {
_, err := q.db.ExecContext(ctx, setAgentDefaultDomain, arg.ID, arg.DefaultDomain)
return err
}
const setAgentSchedulable = `-- name: SetAgentSchedulable :exec
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1
`
@@ -954,7 +1005,7 @@ UPDATE node_agents SET
schedulable = $13,
updated_at = NOW()
WHERE id = $1
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
`
type UpdateAgentParams struct {
@@ -1007,6 +1058,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
)
return i, err
}
@@ -0,0 +1,636 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: apwhy.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countAPKeys = `-- name: CountAPKeys :one
SELECT COUNT(*) FROM api_keys
`
func (q *Queries) CountAPKeys(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAPKeys)
var count int64
err := row.Scan(&count)
return count, err
}
const countAPServices = `-- name: CountAPServices :one
SELECT COUNT(*) FROM api_services
`
func (q *Queries) CountAPServices(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAPServices)
var count int64
err := row.Scan(&count)
return count, err
}
const getAPServiceBySlug = `-- name: GetAPServiceBySlug :one
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`
type GetAPServiceBySlugRow struct {
ID uuid.UUID `json:"id"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
}
func (q *Queries) GetAPServiceBySlug(ctx context.Context, slug string) (GetAPServiceBySlugRow, error) {
row := q.db.QueryRowContext(ctx, getAPServiceBySlug, slug)
var i GetAPServiceBySlugRow
err := row.Scan(
&i.ID,
&i.UpstreamUrl,
&i.RoutePrefix,
&i.UpstreamAuthHeader,
&i.UpstreamAuthValue,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.RequestTimeoutMs,
)
return i, err
}
const getAPServiceForValidate = `-- name: GetAPServiceForValidate :one
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`
type GetAPServiceForValidateRow struct {
UpstreamUrl string `json:"upstream_url"`
HealthPath string `json:"health_path"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
}
func (q *Queries) GetAPServiceForValidate(ctx context.Context, id uuid.UUID) (GetAPServiceForValidateRow, error) {
row := q.db.QueryRowContext(ctx, getAPServiceForValidate, id)
var i GetAPServiceForValidateRow
err := row.Scan(
&i.UpstreamUrl,
&i.HealthPath,
&i.UpstreamAuthHeader,
&i.UpstreamAuthValue,
&i.RequestTimeoutMs,
)
return i, err
}
const insertAPKey = `-- name: InsertAPKey :one
INSERT INTO api_keys (
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`
type InsertAPKeyParams struct {
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
}
func (q *Queries) InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, insertAPKey,
arg.Name,
arg.KeyHash,
arg.KeyPrefix,
arg.Plan,
arg.RpmLimit,
arg.MonthlyQuota,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const insertAPService = `-- name: InsertAPService :one
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`
type InsertAPServiceParams struct {
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
}
func (q *Queries) InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, insertAPService,
arg.Name,
arg.Slug,
arg.UpstreamUrl,
arg.RoutePrefix,
arg.RpmLimit,
arg.MonthlyQuota,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const insertGatewayIncident = `-- name: InsertGatewayIncident :exec
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`
type InsertGatewayIncidentParams struct {
ServiceID uuid.NullUUID `json:"service_id"`
ApiKeyID uuid.NullUUID `json:"api_key_id"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
HttpStatus sql.NullInt32 `json:"http_status"`
}
func (q *Queries) InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error {
_, err := q.db.ExecContext(ctx, insertGatewayIncident,
arg.ServiceID,
arg.ApiKeyID,
arg.Code,
arg.Message,
arg.Severity,
arg.HttpStatus,
)
return err
}
const insertMetricPoint = `-- name: InsertMetricPoint :exec
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`
func (q *Queries) InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error {
_, err := q.db.ExecContext(ctx, insertMetricPoint, labelsJson)
return err
}
const insertServiceIncident = `-- name: InsertServiceIncident :exec
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`
type InsertServiceIncidentParams struct {
ServiceID uuid.NullUUID `json:"service_id"`
Message string `json:"message"`
HttpStatus sql.NullInt32 `json:"http_status"`
}
func (q *Queries) InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error {
_, err := q.db.ExecContext(ctx, insertServiceIncident, arg.ServiceID, arg.Message, arg.HttpStatus)
return err
}
const listAPKeys = `-- name: ListAPKeys :many
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_keys
ORDER BY created_at DESC
`
type ListAPKeysRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (q *Queries) ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error) {
rows, err := q.db.QueryContext(ctx, listAPKeys)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPKeysRow{}
for rows.Next() {
var i ListAPKeysRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.KeyPrefix,
&i.Plan,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAPKeysByPrefix = `-- name: ListAPKeysByPrefix :many
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`
type ListAPKeysByPrefixRow struct {
ID uuid.UUID `json:"id"`
KeyHash string `json:"key_hash"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
AllowedServiceIds string `json:"allowed_service_ids"`
}
func (q *Queries) ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error) {
rows, err := q.db.QueryContext(ctx, listAPKeysByPrefix, keyPrefix)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPKeysByPrefixRow{}
for rows.Next() {
var i ListAPKeysByPrefixRow
if err := rows.Scan(
&i.ID,
&i.KeyHash,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.AllowedServiceIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAPServices = `-- name: ListAPServices :many
SELECT id, name, slug, upstream_url, route_prefix, enabled,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_services
ORDER BY created_at DESC
`
type ListAPServicesRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
Enabled int32 `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (q *Queries) ListAPServices(ctx context.Context) ([]ListAPServicesRow, error) {
rows, err := q.db.QueryContext(ctx, listAPServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPServicesRow{}
for rows.Next() {
var i ListAPServicesRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.UpstreamUrl,
&i.RoutePrefix,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listClientEventPaths = `-- name: ListClientEventPaths :many
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
ORDER BY total DESC, path ASC
LIMIT 20
`
type ListClientEventPathsRow struct {
Path interface{} `json:"path"`
Total int64 `json:"total"`
}
func (q *Queries) ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error) {
rows, err := q.db.QueryContext(ctx, listClientEventPaths)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListClientEventPathsRow{}
for rows.Next() {
var i ListClientEventPathsRow
if err := rows.Scan(&i.Path, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listIncidentStatusCodes = `-- name: ListIncidentStatusCodes :many
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
FROM incident_events
WHERE occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE(http_status, 0)
ORDER BY total DESC, status_code ASC
`
type ListIncidentStatusCodesRow struct {
StatusCode int32 `json:"status_code"`
Total interface{} `json:"total"`
}
func (q *Queries) ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error) {
rows, err := q.db.QueryContext(ctx, listIncidentStatusCodes)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIncidentStatusCodesRow{}
for rows.Next() {
var i ListIncidentStatusCodesRow
if err := rows.Scan(&i.StatusCode, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRequestsByDay = `-- name: ListRequestsByDay :many
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', occurred_at)
ORDER BY DATE_TRUNC('day', occurred_at) ASC
`
type ListRequestsByDayRow struct {
DayBucket string `json:"day_bucket"`
Total int64 `json:"total"`
}
func (q *Queries) ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error) {
rows, err := q.db.QueryContext(ctx, listRequestsByDay)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRequestsByDayRow{}
for rows.Next() {
var i ListRequestsByDayRow
if err := rows.Scan(&i.DayBucket, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTopAPServices = `-- name: ListTopAPServices :many
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
FROM api_services s
LEFT JOIN usage_counters u ON u.service_id = s.id
GROUP BY s.id, s.name
ORDER BY total_requests DESC, s.name ASC
LIMIT 10
`
type ListTopAPServicesRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
TotalRequests interface{} `json:"total_requests"`
}
func (q *Queries) ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error) {
rows, err := q.db.QueryContext(ctx, listTopAPServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListTopAPServicesRow{}
for rows.Next() {
var i ListTopAPServicesRow
if err := rows.Scan(&i.ID, &i.Name, &i.TotalRequests); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAPKeyEnabled = `-- name: SetAPKeyEnabled :exec
UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2
`
type SetAPKeyEnabledParams struct {
Enabled int32 `json:"enabled"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPKeyEnabled(ctx context.Context, arg SetAPKeyEnabledParams) error {
_, err := q.db.ExecContext(ctx, setAPKeyEnabled, arg.Enabled, arg.ID)
return err
}
const setAPServiceEnabled = `-- name: SetAPServiceEnabled :exec
UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2
`
type SetAPServiceEnabledParams struct {
Enabled int32 `json:"enabled"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPServiceEnabled(ctx context.Context, arg SetAPServiceEnabledParams) error {
_, err := q.db.ExecContext(ctx, setAPServiceEnabled, arg.Enabled, arg.ID)
return err
}
const setAPServiceValidation = `-- name: SetAPServiceValidation :exec
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`
type SetAPServiceValidationParams struct {
LastValidationStatus sql.NullString `json:"last_validation_status"`
LastValidationMessage sql.NullString `json:"last_validation_message"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPServiceValidation(ctx context.Context, arg SetAPServiceValidationParams) error {
_, err := q.db.ExecContext(ctx, setAPServiceValidation, arg.LastValidationStatus, arg.LastValidationMessage, arg.ID)
return err
}
const sumRequestMetricSince = `-- name: SumRequestMetricSince :one
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC($1::text, NOW())
`
func (q *Queries) SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error) {
row := q.db.QueryRowContext(ctx, sumRequestMetricSince, dollar_1)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageByKey = `-- name: SumUsageByKey :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE api_key_id = $1 AND period_month = $2
`
type SumUsageByKeyParams struct {
ApiKeyID uuid.UUID `json:"api_key_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error) {
row := q.db.QueryRowContext(ctx, sumUsageByKey, arg.ApiKeyID, arg.PeriodMonth)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageByService = `-- name: SumUsageByService :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE service_id = $1 AND period_month = $2
`
type SumUsageByServiceParams struct {
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error) {
row := q.db.QueryRowContext(ctx, sumUsageByService, arg.ServiceID, arg.PeriodMonth)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageCounters = `-- name: SumUsageCounters :one
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
`
func (q *Queries) SumUsageCounters(ctx context.Context) (interface{}, error) {
row := q.db.QueryRowContext(ctx, sumUsageCounters)
var coalesce interface{}
err := row.Scan(&coalesce)
return coalesce, err
}
const touchAPKeyLastUsed = `-- name: TouchAPKeyLastUsed :exec
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`
func (q *Queries) TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchAPKeyLastUsed, id)
return err
}
const upsertUsageCounter = `-- name: UpsertUsageCounter :exec
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`
type UpsertUsageCounterParams struct {
ApiKeyID uuid.UUID `json:"api_key_id"`
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error {
_, err := q.db.ExecContext(ctx, upsertUsageCounter, arg.ApiKeyID, arg.ServiceID, arg.PeriodMonth)
return err
}
@@ -0,0 +1,218 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: audit.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const insertAuditLog = `-- name: InsertAuditLog :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
type InsertAuditLogParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Action string `json:"action"`
Details pqtype.NullRawMessage `json:"details"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error {
_, err := q.db.ExecContext(ctx, insertAuditLog,
arg.ID,
arg.UserID,
arg.Resource,
arg.ResourceID,
arg.Action,
arg.Details,
arg.Severity,
arg.Category,
arg.Label,
arg.CreatedAt,
)
return err
}
const insertAuditLogWithRequest = `-- name: InsertAuditLogWithRequest :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::inet, $8, $9, $10, $11, $12)
`
type InsertAuditLogWithRequestParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Action string `json:"action"`
Column6 json.RawMessage `json:"column_6"`
Column7 pqtype.Inet `json:"column_7"`
UserAgent sql.NullString `json:"user_agent"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error {
_, err := q.db.ExecContext(ctx, insertAuditLogWithRequest,
arg.ID,
arg.UserID,
arg.Resource,
arg.ResourceID,
arg.Action,
arg.Column6,
arg.Column7,
arg.UserAgent,
arg.Severity,
arg.Category,
arg.Label,
arg.CreatedAt,
)
return err
}
const listAuditBackfillBatch = `-- name: ListAuditBackfillBatch :many
SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500
`
type ListAuditBackfillBatchRow struct {
ID string `json:"id"`
Resource string `json:"resource"`
Action string `json:"action"`
}
func (q *Queries) ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error) {
rows, err := q.db.QueryContext(ctx, listAuditBackfillBatch, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAuditBackfillBatchRow{}
for rows.Next() {
var i ListAuditBackfillBatchRow
if err := rows.Scan(&i.ID, &i.Resource, &i.Action); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listResourceAuditLogs = `-- name: ListResourceAuditLogs :many
SELECT id, COALESCE(user_id::text, '')::text AS user_id, resource,
COALESCE(resource_id::text, '')::text AS resource_id, action,
COALESCE(details::text, '{}')::text AS details,
COALESCE(ip_address::text, '')::text AS ip_address,
COALESCE(user_agent, '') AS user_agent, created_at
FROM audit_logs
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100
`
type ListResourceAuditLogsParams struct {
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
}
type ListResourceAuditLogsRow struct {
ID uuid.UUID `json:"id"`
UserID string `json:"user_id"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
Action string `json:"action"`
Details string `json:"details"`
IpAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error) {
rows, err := q.db.QueryContext(ctx, listResourceAuditLogs, arg.UserID, arg.Resource, arg.ResourceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListResourceAuditLogsRow{}
for rows.Next() {
var i ListResourceAuditLogsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Resource,
&i.ResourceID,
&i.Action,
&i.Details,
&i.IpAddress,
&i.UserAgent,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const pruneAuditLogs = `-- name: PruneAuditLogs :execrows
DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')
`
func (q *Queries) PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error) {
result, err := q.db.ExecContext(ctx, pruneAuditLogs, dollar_1)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setAuditLogMetadata = `-- name: SetAuditLogMetadata :exec
UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid
`
type SetAuditLogMetadataParams struct {
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
Column4 uuid.UUID `json:"column_4"`
}
func (q *Queries) SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error {
_, err := q.db.ExecContext(ctx, setAuditLogMetadata,
arg.Severity,
arg.Category,
arg.Label,
arg.Column4,
)
return err
}
@@ -0,0 +1,335 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: auth.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countUsers = `-- name: CountUsers :one
SELECT COUNT(*) FROM users
`
func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countUsers)
var count int64
err := row.Scan(&count)
return count, err
}
const countUsersByEmail = `-- name: CountUsersByEmail :one
SELECT COUNT(*) FROM users WHERE email = $1
`
func (q *Queries) CountUsersByEmail(ctx context.Context, email string) (int64, error) {
row := q.db.QueryRowContext(ctx, countUsersByEmail, email)
var count int64
err := row.Scan(&count)
return count, err
}
const getAdminUserInfo = `-- name: GetAdminUserInfo :one
SELECT email, COALESCE(name, '') AS name, is_admin
FROM users WHERE id = $1
`
type GetAdminUserInfoRow struct {
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
}
func (q *Queries) GetAdminUserInfo(ctx context.Context, id uuid.UUID) (GetAdminUserInfoRow, error) {
row := q.db.QueryRowContext(ctx, getAdminUserInfo, id)
var i GetAdminUserInfoRow
err := row.Scan(&i.Email, &i.Name, &i.IsAdmin)
return i, err
}
const getUserByEmailForAuth = `-- name: GetUserByEmailForAuth :one
SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE email = $1
`
type GetUserByEmailForAuthRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error) {
row := q.db.QueryRowContext(ctx, getUserByEmailForAuth, email)
var i GetUserByEmailForAuthRow
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE id = $1
`
type GetUserByIDRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
const insertUser = `-- name: InsertUser :one
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
`
type InsertUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
}
type InsertUserRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error) {
row := q.db.QueryRowContext(ctx, insertUser, arg.Email, arg.PasswordHash, arg.Name)
var i InsertUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
const insertUserAdmin = `-- name: InsertUserAdmin :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
`
type InsertUserAdminParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
}
type InsertUserAdminRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error) {
row := q.db.QueryRowContext(ctx, insertUserAdmin,
arg.Email,
arg.PasswordHash,
arg.Name,
arg.IsAdmin,
)
var i InsertUserAdminRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
const listAdminUsers = `-- name: ListAdminUsers :many
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin,
COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')::text AS created_at
FROM users
ORDER BY created_at ASC
`
type ListAdminUsersRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
}
func (q *Queries) ListAdminUsers(ctx context.Context) ([]ListAdminUsersRow, error) {
rows, err := q.db.QueryContext(ctx, listAdminUsers)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAdminUsersRow{}
for rows.Next() {
var i ListAdminUsersRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setUserAdmin = `-- name: SetUserAdmin :execrows
UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2
`
type SetUserAdminParams struct {
IsAdmin bool `json:"is_admin"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setUserAdmin, arg.IsAdmin, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const updateUserProfile = `-- name: UpdateUserProfile :exec
UPDATE users
SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url)
WHERE id = $3
`
type UpdateUserProfileParams struct {
Column1 string `json:"column_1"`
Column2 string `json:"column_2"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
_, err := q.db.ExecContext(ctx, updateUserProfile, arg.Column1, arg.Column2, arg.ID)
return err
}
const upsertAdminUser = `-- name: UpsertAdminUser :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id
`
type UpsertAdminUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
}
func (q *Queries) UpsertAdminUser(ctx context.Context, arg UpsertAdminUserParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, upsertAdminUser, arg.Email, arg.PasswordHash, arg.Name)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const upsertLocalUser = `-- name: UpsertLocalUser :one
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4::varchar, ''), $5)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
`
type UpsertLocalUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
Column4 string `json:"column_4"`
IsAdmin bool `json:"is_admin"`
}
type UpsertLocalUserRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) UpsertLocalUser(ctx context.Context, arg UpsertLocalUserParams) (UpsertLocalUserRow, error) {
row := q.db.QueryRowContext(ctx, upsertLocalUser,
arg.Email,
arg.PasswordHash,
arg.Name,
arg.Column4,
arg.IsAdmin,
)
var i UpsertLocalUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,138 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: builds.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
)
const cancelBuild = `-- name: CancelBuild :execrows
UPDATE builds
SET status = 'cancelled',
progress = 100,
completed_at = $1,
log = COALESCE(log, '') || $2,
updated_at = $1
WHERE id = $3
AND status NOT IN ('success', 'failed', 'cancelled')
`
type CancelBuildParams struct {
CompletedAt sql.NullTime `json:"completed_at"`
Log string `json:"log"`
ID string `json:"id"`
}
func (q *Queries) CancelBuild(ctx context.Context, arg CancelBuildParams) (int64, error) {
result, err := q.db.ExecContext(ctx, cancelBuild, arg.CompletedAt, arg.Log, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getBuild = `-- name: GetBuild :one
SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
FROM builds
WHERE id = $1
`
type GetBuildRow struct {
ID string `json:"id"`
ProjectID sql.NullString `json:"project_id"`
ServiceID sql.NullString `json:"service_id"`
Status string `json:"status"`
Progress int32 `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error sql.NullString `json:"error"`
Log string `json:"log"`
Metadata json.RawMessage `json:"metadata"`
}
func (q *Queries) GetBuild(ctx context.Context, id string) (GetBuildRow, error) {
row := q.db.QueryRowContext(ctx, getBuild, id)
var i GetBuildRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.Status,
&i.Progress,
&i.StartedAt,
&i.CompletedAt,
&i.ImageName,
&i.ImageTag,
&i.Size,
&i.Error,
&i.Log,
&i.Metadata,
)
return i, err
}
const upsertBuild = `-- name: UpsertBuild :exec
INSERT INTO builds
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
ON CONFLICT (id) DO UPDATE SET
project_id = EXCLUDED.project_id,
service_id = EXCLUDED.service_id,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
image_name = EXCLUDED.image_name,
image_tag = EXCLUDED.image_tag,
size = EXCLUDED.size,
error = EXCLUDED.error,
log = EXCLUDED.log,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at
`
type UpsertBuildParams struct {
ID string `json:"id"`
ProjectID sql.NullString `json:"project_id"`
ServiceID sql.NullString `json:"service_id"`
Status string `json:"status"`
Progress int32 `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error sql.NullString `json:"error"`
Log string `json:"log"`
Column13 json.RawMessage `json:"column_13"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) UpsertBuild(ctx context.Context, arg UpsertBuildParams) error {
_, err := q.db.ExecContext(ctx, upsertBuild,
arg.ID,
arg.ProjectID,
arg.ServiceID,
arg.Status,
arg.Progress,
arg.StartedAt,
arg.CompletedAt,
arg.ImageName,
arg.ImageTag,
arg.Size,
arg.Error,
arg.Log,
arg.Column13,
arg.CreatedAt,
)
return err
}
@@ -0,0 +1,332 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: cron.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const deleteCronJob = `-- name: DeleteCronJob :exec
DELETE FROM cron_jobs WHERE id = $1
`
func (q *Queries) DeleteCronJob(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteCronJob, id)
return err
}
const finishCronExecution = `-- name: FinishCronExecution :exec
UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5
`
type FinishCronExecutionParams struct {
FinishedAt sql.NullTime `json:"finished_at"`
Status sql.NullString `json:"status"`
Output sql.NullString `json:"output"`
Error sql.NullString `json:"error"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) FinishCronExecution(ctx context.Context, arg FinishCronExecutionParams) error {
_, err := q.db.ExecContext(ctx, finishCronExecution,
arg.FinishedAt,
arg.Status,
arg.Output,
arg.Error,
arg.ID,
)
return err
}
const getCronJob = `-- name: GetCronJob :one
SELECT id, project_id, service_id, name, schedule, command, timezone,
enabled, last_run_at, next_run_at, last_status, last_output,
retention, created_at, updated_at
FROM cron_jobs WHERE id = $1
`
func (q *Queries) GetCronJob(ctx context.Context, id uuid.UUID) (CronJob, error) {
row := q.db.QueryRowContext(ctx, getCronJob, id)
var i CronJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.Name,
&i.Schedule,
&i.Command,
&i.Timezone,
&i.Enabled,
&i.LastRunAt,
&i.NextRunAt,
&i.LastStatus,
&i.LastOutput,
&i.Retention,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getCronJobForTrigger = `-- name: GetCronJobForTrigger :one
SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1
`
type GetCronJobForTriggerRow struct {
ServiceID uuid.UUID `json:"service_id"`
Command string `json:"command"`
Schedule string `json:"schedule"`
Timezone sql.NullString `json:"timezone"`
Retention sql.NullInt32 `json:"retention"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetCronJobForTrigger(ctx context.Context, id uuid.UUID) (GetCronJobForTriggerRow, error) {
row := q.db.QueryRowContext(ctx, getCronJobForTrigger, id)
var i GetCronJobForTriggerRow
err := row.Scan(
&i.ServiceID,
&i.Command,
&i.Schedule,
&i.Timezone,
&i.Retention,
&i.OwnerID,
)
return i, err
}
const getCronJobOwner = `-- name: GetCronJobOwner :one
SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1
`
func (q *Queries) GetCronJobOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getCronJobOwner, id)
var owner_id uuid.UUID
err := row.Scan(&owner_id)
return owner_id, err
}
const getCronJobProjectID = `-- name: GetCronJobProjectID :one
SELECT project_id FROM cron_jobs WHERE id = $1
`
func (q *Queries) GetCronJobProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getCronJobProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getCronOwnerViaService = `-- name: GetCronOwnerViaService :one
SELECT p.owner_id FROM projects p
JOIN services s ON s.project_id = p.id
WHERE s.id = $1
`
func (q *Queries) GetCronOwnerViaService(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getCronOwnerViaService, id)
var owner_id uuid.UUID
err := row.Scan(&owner_id)
return owner_id, err
}
const insertCronExecution = `-- name: InsertCronExecution :exec
INSERT INTO cron_executions (id, cron_job_id, started_at, status)
VALUES ($1, $2, $3, $4)
`
type InsertCronExecutionParams struct {
ID uuid.UUID `json:"id"`
CronJobID uuid.UUID `json:"cron_job_id"`
StartedAt time.Time `json:"started_at"`
Status sql.NullString `json:"status"`
}
func (q *Queries) InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error {
_, err := q.db.ExecContext(ctx, insertCronExecution,
arg.ID,
arg.CronJobID,
arg.StartedAt,
arg.Status,
)
return err
}
const insertCronJob = `-- name: InsertCronJob :exec
INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
type InsertCronJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone sql.NullString `json:"timezone"`
Enabled sql.NullBool `json:"enabled"`
NextRunAt sql.NullTime `json:"next_run_at"`
Retention sql.NullInt32 `json:"retention"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertCronJob(ctx context.Context, arg InsertCronJobParams) error {
_, err := q.db.ExecContext(ctx, insertCronJob,
arg.ID,
arg.ProjectID,
arg.ServiceID,
arg.Name,
arg.Schedule,
arg.Command,
arg.Timezone,
arg.Enabled,
arg.NextRunAt,
arg.Retention,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const listCronExecutions = `-- name: ListCronExecutions :many
SELECT id, cron_job_id, started_at, finished_at, status, output, error
FROM cron_executions
WHERE cron_job_id = $1
ORDER BY started_at DESC
LIMIT 100
`
func (q *Queries) ListCronExecutions(ctx context.Context, cronJobID uuid.UUID) ([]CronExecution, error) {
rows, err := q.db.QueryContext(ctx, listCronExecutions, cronJobID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CronExecution{}
for rows.Next() {
var i CronExecution
if err := rows.Scan(
&i.ID,
&i.CronJobID,
&i.StartedAt,
&i.FinishedAt,
&i.Status,
&i.Output,
&i.Error,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDueCronJobs = `-- name: ListDueCronJobs :many
SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
FROM cron_jobs
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()
`
type ListDueCronJobsRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone sql.NullString `json:"timezone"`
Enabled sql.NullBool `json:"enabled"`
Retention sql.NullInt32 `json:"retention"`
}
func (q *Queries) ListDueCronJobs(ctx context.Context) ([]ListDueCronJobsRow, error) {
rows, err := q.db.QueryContext(ctx, listDueCronJobs)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDueCronJobsRow{}
for rows.Next() {
var i ListDueCronJobsRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.Name,
&i.Schedule,
&i.Command,
&i.Timezone,
&i.Enabled,
&i.Retention,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const trimCronExecutions = `-- name: TrimCronExecutions :exec
DELETE FROM cron_executions WHERE cron_executions.cron_job_id = $1 AND cron_executions.id NOT IN (
SELECT ce.id FROM cron_executions ce WHERE ce.cron_job_id = $1
ORDER BY ce.started_at DESC LIMIT $2)
`
type TrimCronExecutionsParams struct {
CronJobID uuid.UUID `json:"cron_job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) TrimCronExecutions(ctx context.Context, arg TrimCronExecutionsParams) error {
_, err := q.db.ExecContext(ctx, trimCronExecutions, arg.CronJobID, arg.Limit)
return err
}
const updateCronJobRun = `-- name: UpdateCronJobRun :exec
UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5
`
type UpdateCronJobRunParams struct {
LastRunAt sql.NullTime `json:"last_run_at"`
LastStatus sql.NullString `json:"last_status"`
LastOutput sql.NullString `json:"last_output"`
NextRunAt sql.NullTime `json:"next_run_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateCronJobRun(ctx context.Context, arg UpdateCronJobRunParams) error {
_, err := q.db.ExecContext(ctx, updateCronJobRun,
arg.LastRunAt,
arg.LastStatus,
arg.LastOutput,
arg.NextRunAt,
arg.ID,
)
return err
}
@@ -0,0 +1,536 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: deployments.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const completeDeployment = `-- name: CompleteDeployment :exec
UPDATE deployments
SET status = $1, error = $2, completed_at = $3, updated_at = $3
WHERE id = $4
`
type CompleteDeploymentParams struct {
Status sql.NullString `json:"status"`
Error sql.NullString `json:"error"`
CompletedAt sql.NullTime `json:"completed_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error {
_, err := q.db.ExecContext(ctx, completeDeployment,
arg.Status,
arg.Error,
arg.CompletedAt,
arg.ID,
)
return err
}
const countAccessibleDeployments = `-- name: CountAccessibleDeployments :one
SELECT COUNT(*)
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
`
type CountAccessibleDeploymentsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
func (q *Queries) CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countAccessibleDeployments, arg.OwnerID, arg.Column2)
var count int64
err := row.Scan(&count)
return count, err
}
const countDeploymentsForService = `-- name: CountDeploymentsForService :one
SELECT COUNT(*) FROM deployments WHERE service_id = $1
`
func (q *Queries) CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countDeploymentsForService, serviceID)
var count int64
err := row.Scan(&count)
return count, err
}
const failDeployment = `-- name: FailDeployment :exec
UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3
`
type FailDeploymentParams struct {
Error sql.NullString `json:"error"`
CompletedAt sql.NullTime `json:"completed_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) FailDeployment(ctx context.Context, arg FailDeploymentParams) error {
_, err := q.db.ExecContext(ctx, failDeployment, arg.Error, arg.CompletedAt, arg.ID)
return err
}
const getDeploymentAccess = `-- name: GetDeploymentAccess :one
SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1
`
type GetDeploymentAccessRow struct {
Status sql.NullString `json:"status"`
ServiceID uuid.UUID `json:"service_id"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error) {
row := q.db.QueryRowContext(ctx, getDeploymentAccess, id)
var i GetDeploymentAccessRow
err := row.Scan(&i.Status, &i.ServiceID, &i.OwnerID)
return i, err
}
const getDeploymentForRollback = `-- name: GetDeploymentForRollback :one
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1
`
type GetDeploymentForRollbackRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash sql.NullString `json:"commit_hash"`
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
BuildLog sql.NullString `json:"build_log"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error) {
row := q.db.QueryRowContext(ctx, getDeploymentForRollback, id)
var i GetDeploymentForRollbackRow
err := row.Scan(
&i.ID,
&i.ServiceID,
&i.CommitHash,
&i.Status,
&i.ImageName,
&i.ImageTag,
&i.BuildLog,
&i.RuntimeLog,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
)
return i, err
}
const getDeploymentWithProject = `-- name: GetDeploymentWithProject :one
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.id AS project_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1
`
type GetDeploymentWithProjectRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash sql.NullString `json:"commit_hash"`
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
BuildLog sql.NullString `json:"build_log"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error) {
row := q.db.QueryRowContext(ctx, getDeploymentWithProject, id)
var i GetDeploymentWithProjectRow
err := row.Scan(
&i.ID,
&i.ServiceID,
&i.CommitHash,
&i.Status,
&i.ImageName,
&i.ImageTag,
&i.BuildLog,
&i.RuntimeLog,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ProjectID,
)
return i, err
}
const getServiceForDeployWithOwner = `-- name: GetServiceForDeployWithOwner :one
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
COALESCE(s.static_dir, '') AS static_dir,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1
`
type GetServiceForDeployWithOwnerRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Type sql.NullString `json:"type"`
Status sql.NullString `json:"status"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error) {
row := q.db.QueryRowContext(ctx, getServiceForDeployWithOwner, id)
var i GetServiceForDeployWithOwnerRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Type,
&i.Status,
&i.Image,
&i.Command,
&i.Environment,
&i.GitRepo,
&i.GitBranch,
&i.BuildPath,
&i.Cpu,
&i.Memory,
&i.Replicas,
&i.Port,
&i.Domain,
&i.HealthcheckPath,
&i.RestartPolicy,
&i.Builder,
&i.CpuReserve,
&i.MemoryReserve,
&i.StaticBuildCmd,
&i.StaticDir,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
)
return i, err
}
const getServicePublishedPort = `-- name: GetServicePublishedPort :one
SELECT published_port FROM services WHERE id = $1
`
func (q *Queries) GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error) {
row := q.db.QueryRowContext(ctx, getServicePublishedPort, id)
var published_port int32
err := row.Scan(&published_port)
return published_port, err
}
const insertDeployment = `-- name: InsertDeployment :exec
INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
type InsertDeploymentParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Version string `json:"version"`
CommitHash sql.NullString `json:"commit_hash"`
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error {
_, err := q.db.ExecContext(ctx, insertDeployment,
arg.ID,
arg.ServiceID,
arg.Version,
arg.CommitHash,
arg.Status,
arg.ImageName,
arg.ImageTag,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const listDeploymentsForService = `-- name: ListDeploymentsForService :many
SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
type ListDeploymentsForServiceParams struct {
ServiceID uuid.UUID `json:"service_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListDeploymentsForServiceRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash sql.NullString `json:"commit_hash"`
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
BuildLog sql.NullString `json:"build_log"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error) {
rows, err := q.db.QueryContext(ctx, listDeploymentsForService, arg.ServiceID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDeploymentsForServiceRow{}
for rows.Next() {
var i ListDeploymentsForServiceRow
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.CommitHash,
&i.Status,
&i.ImageName,
&i.ImageTag,
&i.BuildLog,
&i.RuntimeLog,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecentAccessibleDeployments = `-- name: ListRecentAccessibleDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image_name, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC
LIMIT $3 OFFSET $4
`
type ListRecentAccessibleDeploymentsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListRecentAccessibleDeploymentsRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status sql.NullString `json:"status"`
ImageName string `json:"image_name"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentAccessibleDeployments,
arg.OwnerID,
arg.Column2,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentAccessibleDeploymentsRow{}
for rows.Next() {
var i ListRecentAccessibleDeploymentsRow
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.ServiceName,
&i.ProjectName,
&i.Status,
&i.ImageName,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setDeploymentStatus = `-- name: SetDeploymentStatus :exec
UPDATE deployments SET status = $1, updated_at = $2 WHERE id = $3
`
type SetDeploymentStatusParams struct {
Status sql.NullString `json:"status"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetDeploymentStatus(ctx context.Context, arg SetDeploymentStatusParams) error {
_, err := q.db.ExecContext(ctx, setDeploymentStatus, arg.Status, arg.UpdatedAt, arg.ID)
return err
}
const setServiceStatus = `-- name: SetServiceStatus :exec
UPDATE services SET status = $1, updated_at = $2 WHERE id = $3
`
type SetServiceStatusParams struct {
Status sql.NullString `json:"status"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error {
_, err := q.db.ExecContext(ctx, setServiceStatus, arg.Status, arg.UpdatedAt, arg.ID)
return err
}
const syncDeploymentProgress = `-- name: SyncDeploymentProgress :exec
UPDATE deployments
SET status = $1,
image_name = $2,
image_tag = $3,
build_log = $4,
runtime_log = $5,
error = $6,
started_at = $7,
completed_at = $8,
updated_at = $9
WHERE id = $10
`
type SyncDeploymentProgressParams struct {
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
BuildLog sql.NullString `json:"build_log"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error {
_, err := q.db.ExecContext(ctx, syncDeploymentProgress,
arg.Status,
arg.ImageName,
arg.ImageTag,
arg.BuildLog,
arg.RuntimeLog,
arg.Error,
arg.StartedAt,
arg.CompletedAt,
arg.UpdatedAt,
arg.ID,
)
return err
}
@@ -0,0 +1,242 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: domains.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const clearServiceDomainDefault = `-- name: ClearServiceDomainDefault :exec
UPDATE service_domains SET is_default = false WHERE service_id = $1
`
func (q *Queries) ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, clearServiceDomainDefault, serviceID)
return err
}
const countServiceDomains = `-- name: CountServiceDomains :one
SELECT COUNT(*) FROM service_domains WHERE service_id = $1
`
func (q *Queries) CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countServiceDomains, serviceID)
var count int64
err := row.Scan(&count)
return count, err
}
const createServiceDomain = `-- name: CreateServiceDomain :one
INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at
`
type CreateServiceDomainParams struct {
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
}
type CreateServiceDomainRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) CreateServiceDomain(ctx context.Context, arg CreateServiceDomainParams) (CreateServiceDomainRow, error) {
row := q.db.QueryRowContext(ctx, createServiceDomain, arg.ServiceID, arg.Domain, arg.IsDefault)
var i CreateServiceDomainRow
err := row.Scan(
&i.ID,
&i.ServiceID,
&i.Domain,
&i.IsDefault,
&i.CertType,
&i.CertStatus,
&i.CreatedAt,
)
return i, err
}
const deleteServiceDomain = `-- name: DeleteServiceDomain :execrows
DELETE FROM service_domains WHERE id = $1 AND service_id = $2
`
type DeleteServiceDomainParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
}
func (q *Queries) DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteServiceDomain, arg.ID, arg.ServiceID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getServiceAccess = `-- name: GetServiceAccess :one
SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1
`
type GetServiceAccessRow struct {
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
}
func (q *Queries) GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error) {
row := q.db.QueryRowContext(ctx, getServiceAccess, id)
var i GetServiceAccessRow
err := row.Scan(&i.MaintenanceMode, &i.BasicAuthUsers)
return i, err
}
const getServiceDomainDefault = `-- name: GetServiceDomainDefault :one
SELECT domain FROM service_domains WHERE service_id = $1 AND is_default
`
func (q *Queries) GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceDomainDefault, serviceID)
var domain string
err := row.Scan(&domain)
return domain, err
}
const getServiceDomainFirst = `-- name: GetServiceDomainFirst :one
SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1
`
func (q *Queries) GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceDomainFirst, serviceID)
var domain string
err := row.Scan(&domain)
return domain, err
}
const getServicePlacementBrief = `-- name: GetServicePlacementBrief :one
SELECT name, node_id, COALESCE(spread, false) AS spread FROM services WHERE id = $1
`
type GetServicePlacementBriefRow struct {
Name string `json:"name"`
NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"`
}
func (q *Queries) GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error) {
row := q.db.QueryRowContext(ctx, getServicePlacementBrief, id)
var i GetServicePlacementBriefRow
err := row.Scan(&i.Name, &i.NodeID, &i.Spread)
return i, err
}
const listServiceDomains = `-- name: ListServiceDomains :many
SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains
WHERE service_id = $1
ORDER BY is_default DESC, created_at ASC
`
func (q *Queries) ListServiceDomains(ctx context.Context, serviceID uuid.UUID) ([]ServiceDomain, error) {
rows, err := q.db.QueryContext(ctx, listServiceDomains, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ServiceDomain{}
for rows.Next() {
var i ServiceDomain
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.Domain,
&i.IsDefault,
&i.CertType,
&i.CertStatus,
&i.LastCheckedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const serviceWriteAccess = `-- name: ServiceWriteAccess :one
SELECT EXISTS(
SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)
) AS allowed
`
type ServiceWriteAccessParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
func (q *Queries) ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error) {
row := q.db.QueryRowContext(ctx, serviceWriteAccess, arg.ID, arg.OwnerID, arg.Column3)
var allowed bool
err := row.Scan(&allowed)
return allowed, err
}
const setServiceDomainDefault = `-- name: SetServiceDomainDefault :execrows
UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2
`
type SetServiceDomainDefaultParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
}
func (q *Queries) SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setServiceDomainDefault, arg.ID, arg.ServiceID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setServiceLegacyDomain = `-- name: SetServiceLegacyDomain :exec
UPDATE services SET domain = $1 WHERE id = $2
`
type SetServiceLegacyDomainParams struct {
Domain string `json:"domain"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error {
_, err := q.db.ExecContext(ctx, setServiceLegacyDomain, arg.Domain, arg.ID)
return err
}
const touchServiceDomainChecked = `-- name: TouchServiceDomainChecked :exec
UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1
`
func (q *Queries) TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchServiceDomainChecked, id)
return err
}
@@ -0,0 +1,776 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: git.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countGitRepositories = `-- name: CountGitRepositories :one
SELECT COUNT(*)
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
`
type CountGitRepositoriesParams struct {
ProviderID uuid.UUID `json:"provider_id"`
UserID uuid.UUID `json:"user_id"`
Column3 string `json:"column_3"`
}
func (q *Queries) CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countGitRepositories, arg.ProviderID, arg.UserID, arg.Column3)
var count int64
err := row.Scan(&count)
return count, err
}
const countUserRepositories = `-- name: CountUserRepositories :one
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1
`
func (q *Queries) CountUserRepositories(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countUserRepositories, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteGitProvider = `-- name: DeleteGitProvider :execrows
DELETE FROM git_providers WHERE id = $1 AND user_id = $2
`
type DeleteGitProviderParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteGitProvider, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getGitProviderForFetch = `-- name: GetGitProviderForFetch :one
SELECT name, api_url, access_token
FROM git_providers
WHERE id = $1 AND user_id = $2
`
type GetGitProviderForFetchParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitProviderForFetchRow struct {
Name string `json:"name"`
ApiUrl string `json:"api_url"`
AccessToken string `json:"access_token"`
}
func (q *Queries) GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error) {
row := q.db.QueryRowContext(ctx, getGitProviderForFetch, arg.ID, arg.UserID)
var i GetGitProviderForFetchRow
err := row.Scan(&i.Name, &i.ApiUrl, &i.AccessToken)
return i, err
}
const getGitProviderForRepos = `-- name: GetGitProviderForRepos :one
SELECT name, display_name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`
type GetGitProviderForReposParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitProviderForReposRow struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
AccessToken string `json:"access_token"`
ApiUrl string `json:"api_url"`
}
func (q *Queries) GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error) {
row := q.db.QueryRowContext(ctx, getGitProviderForRepos, arg.ID, arg.UserID)
var i GetGitProviderForReposRow
err := row.Scan(
&i.Name,
&i.DisplayName,
&i.AccessToken,
&i.ApiUrl,
)
return i, err
}
const getGitProviderName = `-- name: GetGitProviderName :one
SELECT name FROM git_providers WHERE id = $1 AND user_id = $2
`
type GetGitProviderNameParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error) {
row := q.db.QueryRowContext(ctx, getGitProviderName, arg.ID, arg.UserID)
var name string
err := row.Scan(&name)
return name, err
}
const getGitProviderNameByID = `-- name: GetGitProviderNameByID :one
SELECT name FROM git_providers WHERE id = $1
`
func (q *Queries) GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getGitProviderNameByID, id)
var name string
err := row.Scan(&name)
return name, err
}
const getGitProviderTimestamps = `-- name: GetGitProviderTimestamps :one
SELECT created_at, updated_at FROM git_providers WHERE id = $1
`
type GetGitProviderTimestampsRow struct {
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (GetGitProviderTimestampsRow, error) {
row := q.db.QueryRowContext(ctx, getGitProviderTimestamps, id)
var i GetGitProviderTimestampsRow
err := row.Scan(&i.CreatedAt, &i.UpdatedAt)
return i, err
}
const getGitRepoCloneByFullName = `-- name: GetGitRepoCloneByFullName :one
SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1
`
type GetGitRepoCloneByFullNameParams struct {
FullName string `json:"full_name"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitRepoCloneByFullNameRow struct {
CloneUrl string `json:"clone_url"`
ProviderID uuid.UUID `json:"provider_id"`
}
func (q *Queries) GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error) {
row := q.db.QueryRowContext(ctx, getGitRepoCloneByFullName, arg.FullName, arg.UserID)
var i GetGitRepoCloneByFullNameRow
err := row.Scan(&i.CloneUrl, &i.ProviderID)
return i, err
}
const getGitRepoForPush = `-- name: GetGitRepoForPush :one
SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1
`
type GetGitRepoForPushRow struct {
CloneUrl string `json:"clone_url"`
FullName string `json:"full_name"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRepoForPushRow, error) {
row := q.db.QueryRowContext(ctx, getGitRepoForPush, id)
var i GetGitRepoForPushRow
err := row.Scan(&i.CloneUrl, &i.FullName, &i.UserID)
return i, err
}
const getGitRepoProviderByCloneURL = `-- name: GetGitRepoProviderByCloneURL :one
SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1
`
type GetGitRepoProviderByCloneURLParams struct {
CloneUrl string `json:"clone_url"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getGitRepoProviderByCloneURL, arg.CloneUrl, arg.UserID)
var provider_id uuid.UUID
err := row.Scan(&provider_id)
return provider_id, err
}
const getGitRepositoryByFullName = `-- name: GetGitRepositoryByFullName :one
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url,
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3
`
type GetGitRepositoryByFullNameParams struct {
ProviderID uuid.UUID `json:"provider_id"`
FullName string `json:"full_name"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitRepositoryByFullNameRow struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneUrl string `json:"clone_url"`
WebhookUrl string `json:"webhook_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error) {
row := q.db.QueryRowContext(ctx, getGitRepositoryByFullName, arg.ProviderID, arg.FullName, arg.UserID)
var i GetGitRepositoryByFullNameRow
err := row.Scan(
&i.ID,
&i.ProviderID,
&i.Name,
&i.FullName,
&i.Description,
&i.CloneUrl,
&i.WebhookUrl,
&i.DefaultBranch,
&i.IsPrivate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getGitRepositoryProviderID = `-- name: GetGitRepositoryProviderID :one
SELECT provider_id FROM git_repositories WHERE id = $1 AND user_id = $2
`
type GetGitRepositoryProviderIDParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getGitRepositoryProviderID, arg.ID, arg.UserID)
var provider_id uuid.UUID
err := row.Scan(&provider_id)
return provider_id, err
}
const getGitWebhookForPush = `-- name: GetGitWebhookForPush :one
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, '') AS branch_filter, active
FROM git_webhooks WHERE id = $1
`
type GetGitWebhookForPushRow struct {
WebhookSecret string `json:"webhook_secret"`
RepoID uuid.UUID `json:"repo_id"`
ProviderID uuid.UUID `json:"provider_id"`
BranchFilter string `json:"branch_filter"`
Active sql.NullBool `json:"active"`
}
func (q *Queries) GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error) {
row := q.db.QueryRowContext(ctx, getGitWebhookForPush, id)
var i GetGitWebhookForPushRow
err := row.Scan(
&i.WebhookSecret,
&i.RepoID,
&i.ProviderID,
&i.BranchFilter,
&i.Active,
)
return i, err
}
const insertGitProvider = `-- name: InsertGitProvider :exec
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
`
type InsertGitProviderParams struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ApiUrl string `json:"api_url"`
WebhookUrl string `json:"webhook_url"`
AccessToken string `json:"access_token"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error {
_, err := q.db.ExecContext(ctx, insertGitProvider,
arg.ID,
arg.Name,
arg.DisplayName,
arg.ApiUrl,
arg.WebhookUrl,
arg.AccessToken,
arg.UserID,
)
return err
}
const insertGitRepository = `-- name: InsertGitRepository :one
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
RETURNING created_at, updated_at
`
type InsertGitRepositoryParams struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description sql.NullString `json:"description"`
CloneUrl string `json:"clone_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
}
type InsertGitRepositoryRow struct {
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error) {
row := q.db.QueryRowContext(ctx, insertGitRepository,
arg.ID,
arg.ProviderID,
arg.Name,
arg.FullName,
arg.Description,
arg.CloneUrl,
arg.DefaultBranch,
arg.IsPrivate,
arg.UserID,
)
var i InsertGitRepositoryRow
err := row.Scan(&i.CreatedAt, &i.UpdatedAt)
return i, err
}
const listConnectedRepositories = `-- name: ListConnectedRepositories :many
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, '') AS description, r.clone_url,
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
p.name AS provider_name, p.display_name
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3
`
type ListConnectedRepositoriesParams struct {
UserID uuid.UUID `json:"user_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListConnectedRepositoriesRow struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneUrl string `json:"clone_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ProviderName string `json:"provider_name"`
DisplayName string `json:"display_name"`
}
func (q *Queries) ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error) {
rows, err := q.db.QueryContext(ctx, listConnectedRepositories, arg.UserID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListConnectedRepositoriesRow{}
for rows.Next() {
var i ListConnectedRepositoriesRow
if err := rows.Scan(
&i.ID,
&i.ProviderID,
&i.Name,
&i.FullName,
&i.Description,
&i.CloneUrl,
&i.DefaultBranch,
&i.IsPrivate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ProviderName,
&i.DisplayName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listGitProviders = `-- name: ListGitProviders :many
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC
`
type ListGitProvidersRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ApiUrl string `json:"api_url"`
WebhookUrl string `json:"webhook_url"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error) {
rows, err := q.db.QueryContext(ctx, listGitProviders, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListGitProvidersRow{}
for rows.Next() {
var i ListGitProvidersRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.DisplayName,
&i.ApiUrl,
&i.WebhookUrl,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listGitRepositories = `-- name: ListGitRepositories :many
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url,
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
ORDER BY updated_at DESC
LIMIT $4 OFFSET $5
`
type ListGitRepositoriesParams struct {
ProviderID uuid.UUID `json:"provider_id"`
UserID uuid.UUID `json:"user_id"`
Column3 string `json:"column_3"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListGitRepositoriesRow struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneUrl string `json:"clone_url"`
WebhookUrl string `json:"webhook_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error) {
rows, err := q.db.QueryContext(ctx, listGitRepositories,
arg.ProviderID,
arg.UserID,
arg.Column3,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListGitRepositoriesRow{}
for rows.Next() {
var i ListGitRepositoriesRow
if err := rows.Scan(
&i.ID,
&i.ProviderID,
&i.Name,
&i.FullName,
&i.Description,
&i.CloneUrl,
&i.WebhookUrl,
&i.DefaultBranch,
&i.IsPrivate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listServicesForPush = `-- name: ListServicesForPush :many
SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
s.created_at, s.updated_at
FROM services s
WHERE (s.git_branch = $1 OR $1 = '') AND (s.git_repo = $2 OR s.git_repo = $3)
`
type ListServicesForPushParams struct {
GitBranch sql.NullString `json:"git_branch"`
GitRepo sql.NullString `json:"git_repo"`
GitRepo_2 sql.NullString `json:"git_repo_2"`
}
type ListServicesForPushRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
Cpu string `json:"cpu"`
Memory string `json:"memory"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error) {
rows, err := q.db.QueryContext(ctx, listServicesForPush, arg.GitBranch, arg.GitRepo, arg.GitRepo_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServicesForPushRow{}
for rows.Next() {
var i ListServicesForPushRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Type,
&i.Status,
&i.Image,
&i.Command,
&i.Environment,
&i.GitRepo,
&i.GitBranch,
&i.BuildPath,
&i.Cpu,
&i.Memory,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertGitHubAppProvider = `-- name: UpsertGitHubAppProvider :one
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
ON CONFLICT (name, user_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
api_url = EXCLUDED.api_url,
webhook_url = EXCLUDED.webhook_url,
access_token = EXCLUDED.access_token,
updated_at = NOW()
RETURNING id, created_at, updated_at
`
type UpsertGitHubAppProviderParams struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ApiUrl string `json:"api_url"`
WebhookUrl string `json:"webhook_url"`
AccessToken string `json:"access_token"`
UserID uuid.UUID `json:"user_id"`
}
type UpsertGitHubAppProviderRow struct {
ID uuid.UUID `json:"id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error) {
row := q.db.QueryRowContext(ctx, upsertGitHubAppProvider,
arg.ID,
arg.Name,
arg.DisplayName,
arg.ApiUrl,
arg.WebhookUrl,
arg.AccessToken,
arg.UserID,
)
var i UpsertGitHubAppProviderRow
err := row.Scan(&i.ID, &i.CreatedAt, &i.UpdatedAt)
return i, err
}
const upsertGitRepository = `-- name: UpsertGitRepository :exec
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
ON CONFLICT (provider_id, full_name)
DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
clone_url = EXCLUDED.clone_url,
default_branch = EXCLUDED.default_branch,
is_private = EXCLUDED.is_private,
updated_at = NOW()
`
type UpsertGitRepositoryParams struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description sql.NullString `json:"description"`
CloneUrl string `json:"clone_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error {
_, err := q.db.ExecContext(ctx, upsertGitRepository,
arg.ID,
arg.ProviderID,
arg.Name,
arg.FullName,
arg.Description,
arg.CloneUrl,
arg.DefaultBranch,
arg.IsPrivate,
arg.UserID,
)
return err
}
const upsertGitWebhook = `-- name: UpsertGitWebhook :one
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
ON CONFLICT (repo_id, provider_id)
DO UPDATE SET
events = EXCLUDED.events,
webhook_secret = EXCLUDED.webhook_secret,
active = TRUE,
branch_filter = EXCLUDED.branch_filter,
updated_at = NOW()
RETURNING id, active, created_at, updated_at
`
type UpsertGitWebhookParams struct {
ID uuid.UUID `json:"id"`
RepoID uuid.UUID `json:"repo_id"`
ProviderID uuid.UUID `json:"provider_id"`
Events string `json:"events"`
WebhookSecret string `json:"webhook_secret"`
BranchFilter sql.NullString `json:"branch_filter"`
}
type UpsertGitWebhookRow struct {
ID uuid.UUID `json:"id"`
Active sql.NullBool `json:"active"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) UpsertGitWebhook(ctx context.Context, arg UpsertGitWebhookParams) (UpsertGitWebhookRow, error) {
row := q.db.QueryRowContext(ctx, upsertGitWebhook,
arg.ID,
arg.RepoID,
arg.ProviderID,
arg.Events,
arg.WebhookSecret,
arg.BranchFilter,
)
var i UpsertGitWebhookRow
err := row.Scan(
&i.ID,
&i.Active,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -0,0 +1,623 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: metrics.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
)
const aggregateNodeMetrics = `-- name: AggregateNodeMetrics :many
SELECT date_bin($1::interval, timestamp, '2000-01-01'::timestamptz) AS bucket,
AVG(cpu_usage)::float8 AS avg_cpu,
AVG(memory_usage_percent)::float8 AS avg_memory,
AVG(storage_usage_percent)::float8 AS avg_storage
FROM node_metrics
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4
GROUP BY bucket
ORDER BY bucket ASC
`
type AggregateNodeMetricsParams struct {
Column1 int64 `json:"column_1"`
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type AggregateNodeMetricsRow struct {
Bucket interface{} `json:"bucket"`
AvgCpu float64 `json:"avg_cpu"`
AvgMemory float64 `json:"avg_memory"`
AvgStorage float64 `json:"avg_storage"`
}
// Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
// bucket-sized buckets, then back to timestamptz.
func (q *Queries) AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, aggregateNodeMetrics,
arg.Column1,
arg.NodeID,
arg.Timestamp,
arg.Timestamp_2,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AggregateNodeMetricsRow{}
for rows.Next() {
var i AggregateNodeMetricsRow
if err := rows.Scan(
&i.Bucket,
&i.AvgCpu,
&i.AvgMemory,
&i.AvgStorage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listInstanceMetrics = `-- name: ListInstanceMetrics :many
SELECT instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2
`
type ListInstanceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
}
type ListInstanceMetricsRow struct {
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
}
func (q *Queries) ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listInstanceMetrics, arg.ServiceID, arg.Timestamp)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListInstanceMetricsRow{}
for rows.Next() {
var i ListInstanceMetricsRow
if err := rows.Scan(
&i.InstanceID,
&i.NodeID,
&i.Status,
&i.Cpu,
&i.Memory,
&i.NetworkBytesIn,
&i.NetworkBytesOut,
&i.NetworkPacketsIn,
&i.NetworkPacketsOut,
&i.NetworkConnectionsIn,
&i.NetworkConnectionsOut,
&i.NetworkErrorsIn,
&i.NetworkErrorsOut,
&i.StartTime,
&i.LastSeen,
&i.HealthStatus,
&i.HealthLastCheck,
&i.HealthCheckCount,
&i.HealthFailureCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNodeMetrics = `-- name: ListNodeMetrics :many
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
coalesce(extract(epoch from uptime), 0)::bigint AS uptime_seconds,
processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
type ListNodeMetricsParams struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type ListNodeMetricsRow struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
UptimeSeconds int64 `json:"uptime_seconds"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
}
func (q *Queries) ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listNodeMetrics, arg.NodeID, arg.Timestamp, arg.Timestamp_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListNodeMetricsRow{}
for rows.Next() {
var i ListNodeMetricsRow
if err := rows.Scan(
&i.NodeID,
&i.Timestamp,
&i.CpuUsage,
&i.CpuCores,
&i.LoadAvg1,
&i.LoadAvg5,
&i.LoadAvg15,
&i.MemoryTotal,
&i.MemoryUsed,
&i.MemoryAvailable,
&i.MemoryUsagePercent,
&i.StorageTotal,
&i.StorageUsed,
&i.StorageAvailable,
&i.StorageUsagePercent,
&i.NetworkBytesIn,
&i.NetworkBytesOut,
&i.NetworkPacketsIn,
&i.NetworkPacketsOut,
&i.NetworkConnectionsIn,
&i.NetworkConnectionsOut,
&i.NetworkErrorsIn,
&i.NetworkErrorsOut,
&i.UptimeSeconds,
&i.Processes,
&i.Os,
&i.Kernel,
&i.Architecture,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listServiceMetrics = `-- name: ListServiceMetrics :many
SELECT service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
type ListServiceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type ListServiceMetricsRow struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
}
func (q *Queries) ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listServiceMetrics, arg.ServiceID, arg.Timestamp, arg.Timestamp_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServiceMetricsRow{}
for rows.Next() {
var i ListServiceMetricsRow
if err := rows.Scan(
&i.ServiceID,
&i.ServiceName,
&i.ProjectID,
&i.Timestamp,
&i.RequestsTotal,
&i.RequestsSuccess,
&i.RequestsErrors,
&i.RequestsAvgLatency,
&i.RequestsP95Latency,
&i.RequestsP99Latency,
&i.RequestsThroughput,
&i.ErrorsTotal,
&i.ErrorsRate,
&i.PerformanceResponseTime,
&i.PerformanceThroughput,
&i.PerformanceConcurrency,
&i.PerformanceSaturation,
&i.PerformanceUtilization,
&i.ResourceCpuUsage,
&i.ResourceMemoryUsage,
&i.ResourceStorageUsage,
&i.ResourceNetworkUsage,
&i.ResourceScore,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertInstanceMetrics = `-- name: UpsertInstanceMetrics :exec
INSERT INTO instance_metrics (
service_id, timestamp, instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21
)
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET
node_id = EXCLUDED.node_id,
status = EXCLUDED.status,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
start_time = EXCLUDED.start_time,
last_seen = EXCLUDED.last_seen,
health_status = EXCLUDED.health_status,
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count
`
type UpsertInstanceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
}
func (q *Queries) UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertInstanceMetrics,
arg.ServiceID,
arg.Timestamp,
arg.InstanceID,
arg.NodeID,
arg.Status,
arg.Cpu,
arg.Memory,
arg.NetworkBytesIn,
arg.NetworkBytesOut,
arg.NetworkPacketsIn,
arg.NetworkPacketsOut,
arg.NetworkConnectionsIn,
arg.NetworkConnectionsOut,
arg.NetworkErrorsIn,
arg.NetworkErrorsOut,
arg.StartTime,
arg.LastSeen,
arg.HealthStatus,
arg.HealthLastCheck,
arg.HealthCheckCount,
arg.HealthFailureCount,
)
return err
}
const upsertNodeMetrics = `-- name: UpsertNodeMetrics :exec
INSERT INTO node_metrics (
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28
)
ON CONFLICT (node_id, timestamp) DO UPDATE SET
cpu_usage = EXCLUDED.cpu_usage,
cpu_cores = EXCLUDED.cpu_cores,
load_avg_1 = EXCLUDED.load_avg_1,
load_avg_5 = EXCLUDED.load_avg_5,
load_avg_15 = EXCLUDED.load_avg_15,
memory_total = EXCLUDED.memory_total,
memory_used = EXCLUDED.memory_used,
memory_available = EXCLUDED.memory_available,
memory_usage_percent = EXCLUDED.memory_usage_percent,
storage_total = EXCLUDED.storage_total,
storage_used = EXCLUDED.storage_used,
storage_available = EXCLUDED.storage_available,
storage_usage_percent = EXCLUDED.storage_usage_percent,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture
`
type UpsertNodeMetricsParams struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
Uptime sql.NullInt64 `json:"uptime"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
}
func (q *Queries) UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertNodeMetrics,
arg.NodeID,
arg.Timestamp,
arg.CpuUsage,
arg.CpuCores,
arg.LoadAvg1,
arg.LoadAvg5,
arg.LoadAvg15,
arg.MemoryTotal,
arg.MemoryUsed,
arg.MemoryAvailable,
arg.MemoryUsagePercent,
arg.StorageTotal,
arg.StorageUsed,
arg.StorageAvailable,
arg.StorageUsagePercent,
arg.NetworkBytesIn,
arg.NetworkBytesOut,
arg.NetworkPacketsIn,
arg.NetworkPacketsOut,
arg.NetworkConnectionsIn,
arg.NetworkConnectionsOut,
arg.NetworkErrorsIn,
arg.NetworkErrorsOut,
arg.Uptime,
arg.Processes,
arg.Os,
arg.Kernel,
arg.Architecture,
)
return err
}
const upsertServiceMetrics = `-- name: UpsertServiceMetrics :exec
INSERT INTO service_metrics (
service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23
)
ON CONFLICT (service_id, timestamp) DO UPDATE SET
requests_total = EXCLUDED.requests_total,
requests_success = EXCLUDED.requests_success,
requests_errors = EXCLUDED.requests_errors,
requests_avg_latency = EXCLUDED.requests_avg_latency,
requests_p95_latency = EXCLUDED.requests_p95_latency,
requests_p99_latency = EXCLUDED.requests_p99_latency,
requests_throughput = EXCLUDED.requests_throughput,
errors_total = EXCLUDED.errors_total,
errors_rate = EXCLUDED.errors_rate,
performance_response_time = EXCLUDED.performance_response_time,
performance_throughput = EXCLUDED.performance_throughput,
performance_concurrency = EXCLUDED.performance_concurrency,
performance_saturation = EXCLUDED.performance_saturation,
performance_utilization = EXCLUDED.performance_utilization,
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score
`
type UpsertServiceMetricsParams struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
}
func (q *Queries) UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertServiceMetrics,
arg.ServiceID,
arg.ServiceName,
arg.ProjectID,
arg.Timestamp,
arg.RequestsTotal,
arg.RequestsSuccess,
arg.RequestsErrors,
arg.RequestsAvgLatency,
arg.RequestsP95Latency,
arg.RequestsP99Latency,
arg.RequestsThroughput,
arg.ErrorsTotal,
arg.ErrorsRate,
arg.PerformanceResponseTime,
arg.PerformanceThroughput,
arg.PerformanceConcurrency,
arg.PerformanceSaturation,
arg.PerformanceUtilization,
arg.ResourceCpuUsage,
arg.ResourceMemoryUsage,
arg.ResourceStorageUsage,
arg.ResourceNetworkUsage,
arg.ResourceScore,
)
return err
}
@@ -0,0 +1,174 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: misc.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
const countProjects = `-- name: CountProjects :one
SELECT COUNT(*) FROM projects
`
func (q *Queries) CountProjects(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countProjects)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteProjectVariables = `-- name: DeleteProjectVariables :exec
DELETE FROM project_variables WHERE project_id = $1
`
func (q *Queries) DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteProjectVariables, projectID)
return err
}
const getProjectVariableValue = `-- name: GetProjectVariableValue :one
SELECT value FROM project_variables WHERE project_id = $1 AND key = $2
`
type GetProjectVariableValueParams struct {
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
}
func (q *Queries) GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error) {
row := q.db.QueryRowContext(ctx, getProjectVariableValue, arg.ProjectID, arg.Key)
var value string
err := row.Scan(&value)
return value, err
}
const getRegistryAuth = `-- name: GetRegistryAuth :one
SELECT username, password FROM registries
WHERE owner_id::text = $1 AND host = $2
`
type GetRegistryAuthParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Host string `json:"host"`
}
type GetRegistryAuthRow struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (q *Queries) GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error) {
row := q.db.QueryRowContext(ctx, getRegistryAuth, arg.OwnerID, arg.Host)
var i GetRegistryAuthRow
err := row.Scan(&i.Username, &i.Password)
return i, err
}
const insertEnvVar = `-- name: InsertEnvVar :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertEnvVarParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret sql.NullBool `json:"is_secret"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error {
_, err := q.db.ExecContext(ctx, insertEnvVar,
arg.ID,
arg.ServiceID,
arg.Key,
arg.Value,
arg.IsSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const insertProjectVariable = `-- name: InsertProjectVariable :exec
INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertProjectVariableParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (q *Queries) InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error {
_, err := q.db.ExecContext(ctx, insertProjectVariable,
arg.ID,
arg.ProjectID,
arg.Key,
arg.Value,
arg.IsSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const updateServiceDeployMeta = `-- name: UpdateServiceDeployMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2,
volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4::text, ''), git_branch = NULLIF($5::text, ''), builder = $6
WHERE id = $7
`
type UpdateServiceDeployMetaParams struct {
Port int32 `json:"port"`
HealthcheckPath string `json:"healthcheck_path"`
Column3 json.RawMessage `json:"column_3"`
Column4 string `json:"column_4"`
Column5 string `json:"column_5"`
Builder string `json:"builder"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error {
_, err := q.db.ExecContext(ctx, updateServiceDeployMeta,
arg.Port,
arg.HealthcheckPath,
arg.Column3,
arg.Column4,
arg.Column5,
arg.Builder,
arg.ID,
)
return err
}
const updateServiceRuntimeMeta = `-- name: UpdateServiceRuntimeMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3
`
type UpdateServiceRuntimeMetaParams struct {
Port int32 `json:"port"`
HealthcheckPath string `json:"healthcheck_path"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error {
_, err := q.db.ExecContext(ctx, updateServiceRuntimeMeta, arg.Port, arg.HealthcheckPath, arg.ID)
return err
}
+420 -49
View File
@@ -49,6 +49,66 @@ type AgentHeartbeat struct {
CreatedAt time.Time `json:"created_at"`
}
type ApiKey struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
AllowedServiceIds string `json:"allowed_service_ids"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
}
type ApiService struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
HealthPath string `json:"health_path"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
InternalToken sql.NullString `json:"internal_token"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
LastValidationAt sql.NullTime `json:"last_validation_at"`
LastValidationStatus sql.NullString `json:"last_validation_status"`
LastValidationMessage sql.NullString `json:"last_validation_message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AppSetting struct {
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
UpdatedAt time.Time `json:"updated_at"`
}
type AuditLog struct {
ID uuid.UUID `json:"id"`
Timestamp time.Time `json:"timestamp"`
UserID uuid.NullUUID `json:"user_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Details pqtype.NullRawMessage `json:"details"`
IpAddress pqtype.Inet `json:"ip_address"`
UserAgent sql.NullString `json:"user_agent"`
Success bool `json:"success"`
CreatedAt time.Time `json:"created_at"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
}
type BackupTarget struct {
ID string `json:"id"`
UserID string `json:"user_id"`
@@ -78,6 +138,46 @@ type Banner struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type Build struct {
ID string `json:"id"`
ProjectID sql.NullString `json:"project_id"`
ServiceID sql.NullString `json:"service_id"`
Status string `json:"status"`
Progress int32 `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error sql.NullString `json:"error"`
Log string `json:"log"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ComplianceFramework struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ComplianceReport struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
FrameworkID uuid.UUID `json:"framework_id"`
AssessmentDate time.Time `json:"assessment_date"`
Assessor sql.NullString `json:"assessor"`
OverallStatus string `json:"overall_status"`
Score int32 `json:"score"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ContainerInstance struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -98,6 +198,34 @@ type ContainerInstance struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type CronExecution struct {
ID uuid.UUID `json:"id"`
CronJobID uuid.UUID `json:"cron_job_id"`
StartedAt time.Time `json:"started_at"`
FinishedAt sql.NullTime `json:"finished_at"`
Status sql.NullString `json:"status"`
Output sql.NullString `json:"output"`
Error sql.NullString `json:"error"`
}
type CronJob struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone sql.NullString `json:"timezone"`
Enabled sql.NullBool `json:"enabled"`
LastRunAt sql.NullTime `json:"last_run_at"`
NextRunAt sql.NullTime `json:"next_run_at"`
LastStatus sql.NullString `json:"last_status"`
LastOutput sql.NullString `json:"last_output"`
Retention sql.NullInt32 `json:"retention"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type DatabaseBackup struct {
ID string `json:"id"`
DatabaseID string `json:"database_id"`
@@ -136,11 +264,22 @@ type DatabaseService struct {
}
type Deployment struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Status sql.NullString `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Version string `json:"version"`
CommitHash sql.NullString `json:"commit_hash"`
ImageDigest sql.NullString `json:"image_digest"`
Status sql.NullString `json:"status"`
BuildLog sql.NullString `json:"build_log"`
DeploymentLog sql.NullString `json:"deployment_log"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
}
type Environment struct {
@@ -161,6 +300,91 @@ type EnvironmentVariable struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type GitProvider struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ApiUrl string `json:"api_url"`
WebhookUrl string `json:"webhook_url"`
AccessToken string `json:"access_token"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type GitRepository struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description sql.NullString `json:"description"`
CloneUrl string `json:"clone_url"`
WebhookUrl sql.NullString `json:"webhook_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type GitWebhook struct {
ID uuid.UUID `json:"id"`
RepoID uuid.UUID `json:"repo_id"`
ProviderID uuid.UUID `json:"provider_id"`
Events string `json:"events"`
WebhookSecret string `json:"webhook_secret"`
RemoteWebhookID sql.NullString `json:"remote_webhook_id"`
Active sql.NullBool `json:"active"`
BranchFilter sql.NullString `json:"branch_filter"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type IncidentEvent struct {
ID int32 `json:"id"`
ServiceID uuid.NullUUID `json:"service_id"`
ApiKeyID uuid.NullUUID `json:"api_key_id"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
HttpStatus sql.NullInt32 `json:"http_status"`
Count int32 `json:"count"`
OccurredAt time.Time `json:"occurred_at"`
}
type InstanceMetric struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
CreatedAt sql.NullTime `json:"created_at"`
}
type MetricsTimeseries struct {
ID int32 `json:"id"`
Metric string `json:"metric"`
Value float32 `json:"value"`
LabelsJson sql.NullString `json:"labels_json"`
OccurredAt time.Time `json:"occurred_at"`
}
type NodeAgent struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -178,6 +402,39 @@ type NodeAgent struct {
AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"`
Tags json.RawMessage `json:"tags"`
DefaultDomain string `json:"default_domain"`
}
type NodeMetric struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
Uptime sql.NullInt64 `json:"uptime"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
CreatedAt sql.NullTime `json:"created_at"`
}
type Notification struct {
@@ -192,6 +449,17 @@ type Notification struct {
CreatedAt sql.NullTime `json:"created_at"`
}
type NotificationChannel struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Token sql.NullString `json:"token"`
Enabled bool `json:"enabled"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type OutboundWebhook struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
@@ -205,6 +473,21 @@ type OutboundWebhook struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type PreviewEnvironment struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
}
type Project struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
@@ -223,6 +506,16 @@ type ProjectMember struct {
CreatedAt sql.NullTime `json:"created_at"`
}
type ProjectVariable struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Registry struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
@@ -234,51 +527,67 @@ type Registry struct {
UpdatedAt time.Time `json:"updated_at"`
}
type SecurityScan struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.NullUUID `json:"service_id"`
ScanType string `json:"scan_type"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
Summary pqtype.NullRawMessage `json:"summary"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Service struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"`
ServiceType string `json:"service_type"`
SourceType string `json:"source_type"`
SourceUrl sql.NullString `json:"source_url"`
ImageName sql.NullString `json:"image_name"`
BuildCommand sql.NullString `json:"build_command"`
StartCommand sql.NullString `json:"start_command"`
CpuLimit sql.NullInt32 `json:"cpu_limit"`
MemoryLimit sql.NullInt32 `json:"memory_limit"`
PublicUrl sql.NullString `json:"public_url"`
HealthCheckUrl sql.NullString `json:"health_check_url"`
Status sql.NullString `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
Type sql.NullString `json:"type"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"`
Volumes json.RawMessage `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"`
PlacementTags json.RawMessage `json:"placement_tags"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"`
ServiceType string `json:"service_type"`
SourceType string `json:"source_type"`
SourceUrl sql.NullString `json:"source_url"`
ImageName sql.NullString `json:"image_name"`
BuildCommand sql.NullString `json:"build_command"`
StartCommand sql.NullString `json:"start_command"`
CpuLimit sql.NullInt32 `json:"cpu_limit"`
MemoryLimit sql.NullInt32 `json:"memory_limit"`
PublicUrl sql.NullString `json:"public_url"`
HealthCheckUrl sql.NullString `json:"health_check_url"`
Status sql.NullString `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
Type sql.NullString `json:"type"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"`
Volumes json.RawMessage `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"`
SleepEnabled bool `json:"sleep_enabled"`
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
PlacementTags json.RawMessage `json:"placement_tags"`
TraefikLabels json.RawMessage `json:"traefik_labels"`
}
type ServiceDomain struct {
@@ -292,6 +601,33 @@ type ServiceDomain struct {
CreatedAt time.Time `json:"created_at"`
}
type ServiceMetric struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
CreatedAt sql.NullTime `json:"created_at"`
}
type ServiceTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -307,6 +643,26 @@ type ServiceTemplate struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type UsageCounter struct {
ID int32 `json:"id"`
ApiKeyID uuid.UUID `json:"api_key_id"`
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
RequestCount int32 `json:"request_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
AvatarUrl sql.NullString `json:"avatar_url"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
IsAdmin bool `json:"is_admin"`
}
type UserInvite struct {
ID uuid.UUID `json:"id"`
TokenHash string `json:"token_hash"`
@@ -331,6 +687,21 @@ type UserToken struct {
CreatedAt sql.NullTime `json:"created_at"`
}
type Vulnerability struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
Title string `json:"title"`
Description sql.NullString `json:"description"`
ServiceID uuid.NullUUID `json:"service_id"`
ProjectID uuid.UUID `json:"project_id"`
Status string `json:"status"`
FoundAt time.Time `json:"found_at"`
ResolvedAt sql.NullTime `json:"resolved_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WebhookDelivery struct {
ID uuid.UUID `json:"id"`
WebhookID uuid.UUID `json:"webhook_id"`
@@ -53,6 +53,135 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification
return err
}
const deleteNotificationChannel = `-- name: DeleteNotificationChannel :execrows
DELETE FROM notification_channels WHERE id = $1 AND user_id = $2
`
type DeleteNotificationChannelParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const insertNotificationChannel = `-- name: InsertNotificationChannel :one
INSERT INTO notification_channels (user_id, kind, endpoint, token)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at
`
type InsertNotificationChannelParams struct {
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Token sql.NullString `json:"token"`
}
func (q *Queries) InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) {
row := q.db.QueryRowContext(ctx, insertNotificationChannel,
arg.UserID,
arg.Kind,
arg.Endpoint,
arg.Token,
)
var i NotificationChannel
err := row.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listAllNotificationChannelsByUser = `-- name: ListAllNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1
ORDER BY created_at
`
func (q *Queries) ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) {
rows, err := q.db.QueryContext(ctx, listAllNotificationChannelsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []NotificationChannel{}
for rows.Next() {
var i NotificationChannel
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationChannelsByUser = `-- name: ListNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1 AND enabled
ORDER BY created_at
`
func (q *Queries) ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) {
rows, err := q.db.QueryContext(ctx, listNotificationChannelsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []NotificationChannel{}
for rows.Next() {
var i NotificationChannel
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationsByUser = `-- name: ListNotificationsByUser :many
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
FROM notifications
@@ -0,0 +1,258 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: operations.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const listActiveOperationDeployments = `-- name: ListActiveOperationDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY d.created_at ASC
`
type ListActiveOperationDeploymentsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
type ListActiveOperationDeploymentsRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status sql.NullString `json:"status"`
Image string `json:"image"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) ListActiveOperationDeployments(ctx context.Context, arg ListActiveOperationDeploymentsParams) ([]ListActiveOperationDeploymentsRow, error) {
rows, err := q.db.QueryContext(ctx, listActiveOperationDeployments, arg.OwnerID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListActiveOperationDeploymentsRow{}
for rows.Next() {
var i ListActiveOperationDeploymentsRow
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.ServiceName,
&i.ProjectName,
&i.Status,
&i.Image,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecentCronRuns = `-- name: ListRecentCronRuns :many
SELECT e.id, j.name AS job_name, j.schedule, e.status, e.started_at, e.finished_at, e.error
FROM cron_executions e
JOIN cron_jobs j ON j.id = e.cron_job_id
JOIN projects p ON p.id = j.project_id
WHERE e.started_at > NOW() - INTERVAL '24 hours'
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY e.started_at DESC
LIMIT 30
`
type ListRecentCronRunsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
type ListRecentCronRunsRow struct {
ID uuid.UUID `json:"id"`
JobName string `json:"job_name"`
Schedule string `json:"schedule"`
Status sql.NullString `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt sql.NullTime `json:"finished_at"`
Error sql.NullString `json:"error"`
}
func (q *Queries) ListRecentCronRuns(ctx context.Context, arg ListRecentCronRunsParams) ([]ListRecentCronRunsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentCronRuns, arg.OwnerID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentCronRunsRow{}
for rows.Next() {
var i ListRecentCronRunsRow
if err := rows.Scan(
&i.ID,
&i.JobName,
&i.Schedule,
&i.Status,
&i.StartedAt,
&i.FinishedAt,
&i.Error,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecentFailedDeployments = `-- name: ListRecentFailedDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('failed','cancelled','rolled_back')
AND d.updated_at > NOW() - INTERVAL '24 hours'
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY d.updated_at DESC
LIMIT 20
`
type ListRecentFailedDeploymentsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
type ListRecentFailedDeploymentsRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status sql.NullString `json:"status"`
Image string `json:"image"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) ListRecentFailedDeployments(ctx context.Context, arg ListRecentFailedDeploymentsParams) ([]ListRecentFailedDeploymentsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentFailedDeployments, arg.OwnerID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentFailedDeploymentsRow{}
for rows.Next() {
var i ListRecentFailedDeploymentsRow
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.ServiceName,
&i.ProjectName,
&i.Status,
&i.Image,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecentOperationBackups = `-- name: ListRecentOperationBackups :many
SELECT b.id, b.database_id, ds.name AS db_name, b.status, b.size, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services ds ON ds.id = b.database_id
WHERE b.created_at > NOW() - INTERVAL '24 hours'
AND (ds.user_id = $1 OR $2::bool)
ORDER BY b.created_at DESC
LIMIT 20
`
type ListRecentOperationBackupsParams struct {
UserID string `json:"user_id"`
Column2 bool `json:"column_2"`
}
type ListRecentOperationBackupsRow struct {
ID string `json:"id"`
DatabaseID string `json:"database_id"`
DbName string `json:"db_name"`
Status string `json:"status"`
Size string `json:"size"`
CreatedAt sql.NullTime `json:"created_at"`
CompletedAt sql.NullTime `json:"completed_at"`
}
func (q *Queries) ListRecentOperationBackups(ctx context.Context, arg ListRecentOperationBackupsParams) ([]ListRecentOperationBackupsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentOperationBackups, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentOperationBackupsRow{}
for rows.Next() {
var i ListRecentOperationBackupsRow
if err := rows.Scan(
&i.ID,
&i.DatabaseID,
&i.DbName,
&i.Status,
&i.Size,
&i.CreatedAt,
&i.CompletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -0,0 +1,635 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: preview_environments.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const countActivePreviewsForBranch = `-- name: CountActivePreviewsForBranch :one
SELECT COUNT(*) FROM preview_environments
WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped')
`
type CountActivePreviewsForBranchParams struct {
ServiceID uuid.UUID `json:"service_id"`
BranchName string `json:"branch_name"`
}
func (q *Queries) CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countActivePreviewsForBranch, arg.ServiceID, arg.BranchName)
var count int64
err := row.Scan(&count)
return count, err
}
const createPreviewDomain = `-- name: CreatePreviewDomain :exec
INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls')
`
type CreatePreviewDomainParams struct {
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
}
func (q *Queries) CreatePreviewDomain(ctx context.Context, arg CreatePreviewDomainParams) error {
_, err := q.db.ExecContext(ctx, createPreviewDomain, arg.ServiceID, arg.Domain)
return err
}
const createPreviewEnvironment = `-- name: CreatePreviewEnvironment :exec
INSERT INTO preview_environments
(id, project_id, service_id, preview_service_id, branch_name, pr_number, environment,
status, url, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
type CreatePreviewEnvironmentParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreatePreviewEnvironment(ctx context.Context, arg CreatePreviewEnvironmentParams) error {
_, err := q.db.ExecContext(ctx, createPreviewEnvironment,
arg.ID,
arg.ProjectID,
arg.ServiceID,
arg.PreviewServiceID,
arg.BranchName,
arg.PrNumber,
arg.Environment,
arg.Status,
arg.Url,
arg.ExpiresAt,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteAllServiceDomains = `-- name: DeleteAllServiceDomains :exec
DELETE FROM service_domains WHERE service_id = $1
`
func (q *Queries) DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteAllServiceDomains, serviceID)
return err
}
const deletePreviewEnvironment = `-- name: DeletePreviewEnvironment :exec
DELETE FROM preview_environments WHERE id = $1
`
func (q *Queries) DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deletePreviewEnvironment, id)
return err
}
const getPreviewEnvironment = `-- name: GetPreviewEnvironment :one
SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id AS svc_id, s.name AS service_name, s.type AS service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))
`
type GetPreviewEnvironmentParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
type GetPreviewEnvironmentRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
SvcID uuid.NullUUID `json:"svc_id"`
ServiceName sql.NullString `json:"service_name"`
ServiceType sql.NullString `json:"service_type"`
}
func (q *Queries) GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error) {
row := q.db.QueryRowContext(ctx, getPreviewEnvironment, arg.ID, arg.OwnerID, arg.Column3)
var i GetPreviewEnvironmentRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.PreviewServiceID,
&i.BranchName,
&i.PrNumber,
&i.Environment,
&i.Status,
&i.Url,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SvcID,
&i.ServiceName,
&i.ServiceType,
)
return i, err
}
const getPreviewEnvironmentForPromote = `-- name: GetPreviewEnvironmentForPromote :one
SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)
`
type GetPreviewEnvironmentForPromoteParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
type GetPreviewEnvironmentForPromoteRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
Environment string `json:"environment"`
Status string `json:"status"`
}
func (q *Queries) GetPreviewEnvironmentForPromote(ctx context.Context, arg GetPreviewEnvironmentForPromoteParams) (GetPreviewEnvironmentForPromoteRow, error) {
row := q.db.QueryRowContext(ctx, getPreviewEnvironmentForPromote, arg.ID, arg.OwnerID, arg.Column3)
var i GetPreviewEnvironmentForPromoteRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.PreviewServiceID,
&i.BranchName,
&i.Environment,
&i.Status,
)
return i, err
}
const getPreviewEnvironmentForWrite = `-- name: GetPreviewEnvironmentForWrite :one
SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)
`
type GetPreviewEnvironmentForWriteParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
type GetPreviewEnvironmentForWriteRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetPreviewEnvironmentForWrite(ctx context.Context, arg GetPreviewEnvironmentForWriteParams) (GetPreviewEnvironmentForWriteRow, error) {
row := q.db.QueryRowContext(ctx, getPreviewEnvironmentForWrite, arg.ID, arg.OwnerID, arg.Column3)
var i GetPreviewEnvironmentForWriteRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.BranchName,
&i.PrNumber,
&i.Environment,
&i.Status,
&i.Url,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getPreviewEnvironmentOwner = `-- name: GetPreviewEnvironmentOwner :one
SELECT p.owner_id
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1
`
func (q *Queries) GetPreviewEnvironmentOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getPreviewEnvironmentOwner, id)
var owner_id uuid.UUID
err := row.Scan(&owner_id)
return owner_id, err
}
const getPreviewServiceID = `-- name: GetPreviewServiceID :one
SELECT preview_service_id FROM preview_environments WHERE id = $1
`
func (q *Queries) GetPreviewServiceID(ctx context.Context, id uuid.UUID) (uuid.NullUUID, error) {
row := q.db.QueryRowContext(ctx, getPreviewServiceID, id)
var preview_service_id uuid.NullUUID
err := row.Scan(&preview_service_id)
return preview_service_id, err
}
const getProjectBrief = `-- name: GetProjectBrief :one
SELECT id, name, owner_id FROM projects WHERE id = $1
`
type GetProjectBriefRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetProjectBrief(ctx context.Context, id uuid.UUID) (GetProjectBriefRow, error) {
row := q.db.QueryRowContext(ctx, getProjectBrief, id)
var i GetProjectBriefRow
err := row.Scan(&i.ID, &i.Name, &i.OwnerID)
return i, err
}
const getServiceForDeploy = `-- name: GetServiceForDeploy :one
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
COALESCE(s.static_dir, '') AS static_dir,
s.created_at, s.updated_at
FROM services s
WHERE s.id = $1
`
type GetServiceForDeployRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Type sql.NullString `json:"type"`
Status sql.NullString `json:"status"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error) {
row := q.db.QueryRowContext(ctx, getServiceForDeploy, id)
var i GetServiceForDeployRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Type,
&i.Status,
&i.Image,
&i.Command,
&i.Environment,
&i.GitRepo,
&i.GitBranch,
&i.BuildPath,
&i.Cpu,
&i.Memory,
&i.Replicas,
&i.Port,
&i.Domain,
&i.HealthcheckPath,
&i.RestartPolicy,
&i.Builder,
&i.CpuReserve,
&i.MemoryReserve,
&i.StaticBuildCmd,
&i.StaticDir,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getServiceTypeBrief = `-- name: GetServiceTypeBrief :one
SELECT id, name, COALESCE(type, service_type, '') AS service_type FROM services WHERE id = $1 AND project_id = $2
`
type GetServiceTypeBriefParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
type GetServiceTypeBriefRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ServiceType string `json:"service_type"`
}
func (q *Queries) GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error) {
row := q.db.QueryRowContext(ctx, getServiceTypeBrief, arg.ID, arg.ProjectID)
var i GetServiceTypeBriefRow
err := row.Scan(&i.ID, &i.Name, &i.ServiceType)
return i, err
}
const insertPendingDeployment = `-- name: InsertPendingDeployment :exec
INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())
`
type InsertPendingDeploymentParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Version string `json:"version"`
CommitHash sql.NullString `json:"commit_hash"`
}
func (q *Queries) InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error {
_, err := q.db.ExecContext(ctx, insertPendingDeployment,
arg.ID,
arg.ServiceID,
arg.Version,
arg.CommitHash,
)
return err
}
const listExpiredPreviewsForUser = `-- name: ListExpiredPreviewsForUser :many
SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'
`
type ListExpiredPreviewsForUserParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
type ListExpiredPreviewsForUserRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
Environment string `json:"environment"`
}
func (q *Queries) ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error) {
rows, err := q.db.QueryContext(ctx, listExpiredPreviewsForUser, arg.OwnerID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListExpiredPreviewsForUserRow{}
for rows.Next() {
var i ListExpiredPreviewsForUserRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.PreviewServiceID,
&i.BranchName,
&i.Environment,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPreviewEnvironmentsForProject = `-- name: ListPreviewEnvironmentsForProject :many
SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id AS svc_id, s.name AS service_name, s.type AS service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1
ORDER BY pe.created_at DESC
`
type ListPreviewEnvironmentsForProjectRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
SvcID uuid.NullUUID `json:"svc_id"`
ServiceName sql.NullString `json:"service_name"`
ServiceType sql.NullString `json:"service_type"`
}
func (q *Queries) ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, error) {
rows, err := q.db.QueryContext(ctx, listPreviewEnvironmentsForProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListPreviewEnvironmentsForProjectRow{}
for rows.Next() {
var i ListPreviewEnvironmentsForProjectRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.PreviewServiceID,
&i.BranchName,
&i.PrNumber,
&i.Environment,
&i.Status,
&i.Url,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SvcID,
&i.ServiceName,
&i.ServiceType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSweepablePreviews = `-- name: ListSweepablePreviews :many
SELECT id, preview_service_id FROM preview_environments
WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped')
`
type ListSweepablePreviewsRow struct {
ID uuid.UUID `json:"id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
}
func (q *Queries) ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error) {
rows, err := q.db.QueryContext(ctx, listSweepablePreviews)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSweepablePreviewsRow{}
for rows.Next() {
var i ListSweepablePreviewsRow
if err := rows.Scan(&i.ID, &i.PreviewServiceID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markPreviewEnvironmentStatus = `-- name: MarkPreviewEnvironmentStatus :exec
UPDATE preview_environments SET status = $1, updated_at = NOW() WHERE id = $2
`
type MarkPreviewEnvironmentStatusParams struct {
Status string `json:"status"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) MarkPreviewEnvironmentStatus(ctx context.Context, arg MarkPreviewEnvironmentStatusParams) error {
_, err := q.db.ExecContext(ctx, markPreviewEnvironmentStatus, arg.Status, arg.ID)
return err
}
const syncPreviewStatuses = `-- name: SyncPreviewStatuses :exec
UPDATE preview_environments pe
SET status = derived.st, updated_at = NOW()
FROM (
SELECT pe2.id,
CASE
WHEN s.status IN ('running', 'deployed') THEN 'running'
WHEN s.status IN ('failed', 'error') THEN 'failed'
WHEN s.status IN ('building', 'deploying', 'queued', 'pending', 'cloning') THEN 'building'
ELSE pe2.status
END AS st
FROM preview_environments pe2
JOIN services s ON pe2.preview_service_id = s.id
) derived
WHERE pe.id = derived.id
AND pe.status IN ('building', 'running', 'failed')
AND pe.status <> derived.st
`
func (q *Queries) SyncPreviewStatuses(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, syncPreviewStatuses)
return err
}
const updatePreviewEnvironment = `-- name: UpdatePreviewEnvironment :exec
UPDATE preview_environments
SET status = $1, url = $2, expires_at = $3, updated_at = $4
WHERE id = $5
`
type UpdatePreviewEnvironmentParams struct {
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error {
_, err := q.db.ExecContext(ctx, updatePreviewEnvironment,
arg.Status,
arg.Url,
arg.ExpiresAt,
arg.UpdatedAt,
arg.ID,
)
return err
}
const updateServicePreviewBranch = `-- name: UpdateServicePreviewBranch :exec
UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2
`
type UpdateServicePreviewBranchParams struct {
GitBranch sql.NullString `json:"git_branch"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error {
_, err := q.db.ExecContext(ctx, updateServicePreviewBranch, arg.GitBranch, arg.ID)
return err
}
@@ -12,6 +12,17 @@ import (
"github.com/google/uuid"
)
const countEnvironmentServices = `-- name: CountEnvironmentServices :one
SELECT COUNT(*) FROM services WHERE environment_id = $1
`
func (q *Queries) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countEnvironmentServices, environmentID)
var count int64
err := row.Scan(&count)
return count, err
}
const countProjectsByUser = `-- name: CountProjectsByUser :one
SELECT COUNT(*)::bigint AS total
FROM projects p
@@ -86,6 +97,20 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
return result.RowsAffected()
}
const deleteProjectEnvironment = `-- name: DeleteProjectEnvironment :exec
DELETE FROM environments WHERE id = $1 AND project_id = $2
`
type DeleteProjectEnvironmentParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error {
_, err := q.db.ExecContext(ctx, deleteProjectEnvironment, arg.ID, arg.ProjectID)
return err
}
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
@@ -123,6 +148,30 @@ func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDF
return i, err
}
const getProjectEnvironmentByName = `-- name: GetProjectEnvironmentByName :one
SELECT id, name, project_id, created_at, updated_at
FROM environments
WHERE project_id = $1 AND name = $2
`
type GetProjectEnvironmentByNameParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error) {
row := q.db.QueryRowContext(ctx, getProjectEnvironmentByName, arg.ProjectID, arg.Name)
var i Environment
err := row.Scan(
&i.ID,
&i.Name,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getProjectOwnerByID = `-- name: GetProjectOwnerByID :one
SELECT owner_id
FROM projects
@@ -212,6 +261,53 @@ func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
return items, nil
}
const listProjectEnvironments = `-- name: ListProjectEnvironments :many
SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at,
(SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count
FROM environments e
WHERE e.project_id = $1
ORDER BY e.created_at
`
type ListProjectEnvironmentsRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ServiceCount int64 `json:"service_count"`
}
func (q *Queries) ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error) {
rows, err := q.db.QueryContext(ctx, listProjectEnvironments, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListProjectEnvironmentsRow{}
for rows.Next() {
var i ListProjectEnvironmentsRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ServiceCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
SELECT
@@ -13,13 +13,37 @@ import (
)
type Querier interface {
// Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
// bucket-sized buckets, then back to timestamptz.
AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error)
CancelBuild(ctx context.Context, arg CancelBuildParams) (int64, error)
CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error)
ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error
CloneServiceRow(ctx context.Context, arg CloneServiceRowParams) error
CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error
ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error)
CountAPKeys(ctx context.Context) (int64, error)
CountAPServices(ctx context.Context) (int64, error)
CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error)
CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error)
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error)
CountProjects(ctx context.Context) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
CountUserRepositories(ctx context.Context, userID uuid.UUID) (int64, error)
CountUsers(ctx context.Context) (int64, error)
CountUsersByEmail(ctx context.Context, email string) (int64, error)
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
@@ -31,7 +55,10 @@ type Querier interface {
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
CreatePreviewDomain(ctx context.Context, arg CreatePreviewDomainParams) error
CreatePreviewEnvironment(ctx context.Context, arg CreatePreviewEnvironmentParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceDomain(ctx context.Context, arg CreateServiceDomainParams) (CreateServiceDomainRow, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error)
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
@@ -39,87 +66,247 @@ type Querier interface {
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error
DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error
DeleteAppSetting(ctx context.Context, key string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteCronJob(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteEnvVarsForService(ctx context.Context, serviceID uuid.UUID) error
DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error)
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error
DeleteServiceByID(ctx context.Context, id uuid.UUID) error
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error)
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
FailDeployment(ctx context.Context, arg FailDeploymentParams) error
FailInterruptedDeployments(ctx context.Context) error
FinishCronExecution(ctx context.Context, arg FinishCronExecutionParams) error
GetAPServiceBySlug(ctx context.Context, slug string) (GetAPServiceBySlugRow, error)
GetAPServiceForValidate(ctx context.Context, id uuid.UUID) (GetAPServiceForValidateRow, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAdminUserInfo(ctx context.Context, id uuid.UUID) (GetAdminUserInfoRow, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetAppSetting(ctx context.Context, key string) (string, error)
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
GetBuild(ctx context.Context, id string) (GetBuildRow, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
GetCronJob(ctx context.Context, id uuid.UUID) (CronJob, error)
GetCronJobForTrigger(ctx context.Context, id uuid.UUID) (GetCronJobForTriggerRow, error)
GetCronJobOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetCronJobProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetCronOwnerViaService(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error)
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error)
GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error)
GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error)
GetEnvironmentProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error)
GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error)
GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error)
GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, error)
GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (GetGitProviderTimestampsRow, error)
GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error)
GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRepoForPushRow, error)
GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error)
GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error)
GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, error)
GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error)
GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error)
GetLastDeployedImage(ctx context.Context, serviceID uuid.UUID) (string, error)
GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error)
GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error)
GetNodeAgentName(ctx context.Context, id string) (string, error)
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error)
GetPreviewEnvironmentForPromote(ctx context.Context, arg GetPreviewEnvironmentForPromoteParams) (GetPreviewEnvironmentForPromoteRow, error)
GetPreviewEnvironmentForWrite(ctx context.Context, arg GetPreviewEnvironmentForWriteParams) (GetPreviewEnvironmentForWriteRow, error)
GetPreviewEnvironmentOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetPreviewServiceID(ctx context.Context, id uuid.UUID) (uuid.NullUUID, error)
GetProjectBrief(ctx context.Context, id uuid.UUID) (GetProjectBriefRow, error)
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error)
GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error)
GetProjectReadAccess(ctx context.Context, id uuid.UUID) (GetProjectReadAccessRow, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error)
GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error)
GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error)
GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServiceDomainText(ctx context.Context, id uuid.UUID) (string, error)
GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error)
GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error)
GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error)
GetServiceNameProjectPort(ctx context.Context, id uuid.UUID) (GetServiceNameProjectPortRow, error)
GetServiceNodeID(ctx context.Context, id uuid.UUID) (sql.NullString, error)
GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error)
GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetServicePort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceProjectAndStatus(ctx context.Context, id uuid.UUID) (GetServiceProjectAndStatusRow, error)
GetServiceProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error)
GetServiceSleep(ctx context.Context, id uuid.UUID) (GetServiceSleepRow, error)
GetServiceSpread(ctx context.Context, id uuid.UUID) (bool, error)
GetServiceStatusText(ctx context.Context, id uuid.UUID) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetServiceTraefikLabels(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error)
GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error)
GetServiceVolumes(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error)
GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error)
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserIsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error)
GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error)
InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error
InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error
InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error
InsertCronJob(ctx context.Context, arg InsertCronJobParams) error
InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error
InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error
InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error
InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error
InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error)
InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error
InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error)
InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error)
ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error)
ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error)
ListAPServices(ctx context.Context) ([]ListAPServicesRow, error)
ListActiveBanners(ctx context.Context) ([]Banner, error)
ListActiveOperationDeployments(ctx context.Context, arg ListActiveOperationDeploymentsParams) ([]ListActiveOperationDeploymentsRow, error)
ListAdminUserIDs(ctx context.Context) ([]uuid.UUID, error)
ListAdminUsers(ctx context.Context) ([]ListAdminUsersRow, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error)
ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error)
ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListCronExecutions(ctx context.Context, cronJobID uuid.UUID) ([]CronExecution, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error)
ListDueCronJobs(ctx context.Context) ([]ListDueCronJobsRow, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error)
ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error)
ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error)
ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error)
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error)
ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error)
ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListPendingProjects(ctx context.Context) ([]Project, error)
ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, error)
ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error)
ListProjectServiceRefs(ctx context.Context, projectID uuid.UUID) ([]ListProjectServiceRefsRow, error)
// Visibility model: a project is readable when it is approved for public
// display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error)
ListRecentCronRuns(ctx context.Context, arg ListRecentCronRunsParams) ([]ListRecentCronRunsRow, error)
ListRecentFailedDeployments(ctx context.Context, arg ListRecentFailedDeploymentsParams) ([]ListRecentFailedDeploymentsRow, error)
ListRecentOperationBackups(ctx context.Context, arg ListRecentOperationBackupsParams) ([]ListRecentOperationBackupsRow, error)
ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error)
ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error)
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error)
// Distinct agents holding inventory rows for a service.
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
ListServiceDomains(ctx context.Context, serviceID uuid.UUID) ([]ServiceDomain, error)
ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListServiceVariableValues(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariableValuesRow, error)
ListServiceVariablesWithSecret(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariablesWithSecretRow, error)
ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error)
ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error)
ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error)
ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error)
ListUserInvites(ctx context.Context) ([]UserInvite, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error)
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
MarkPreviewEnvironmentStatus(ctx context.Context, arg MarkPreviewEnvironmentStatusParams) error
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
MoveServiceProject(ctx context.Context, arg MoveServiceProjectParams) (int64, error)
// Resource-aware: lowest memory utilisation first, then cpu, then container
// count. Agents without telemetry sort last (NULL → worst score).
PickLeastLoadedAgent(ctx context.Context) (string, error)
// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
// carrying every required placement tag.
PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error)
ProjectMemberExists(ctx context.Context, arg ProjectMemberExistsParams) (bool, error)
PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error)
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error)
ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error)
SetAPKeyEnabled(ctx context.Context, arg SetAPKeyEnabledParams) error
SetAPServiceEnabled(ctx context.Context, arg SetAPServiceEnabledParams) error
SetAPServiceValidation(ctx context.Context, arg SetAPServiceValidationParams) error
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentStatus(ctx context.Context, arg SetAgentStatusParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
@@ -129,9 +316,25 @@ type Querier interface {
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetDeploymentStatus(ctx context.Context, arg SetDeploymentStatusParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error)
SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error
SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error
SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error
SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error
SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (int64, error)
SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error)
SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error)
SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error)
SumUsageCounters(ctx context.Context) (interface{}, error)
SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error
SyncPreviewStatuses(ctx context.Context) error
TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error
TouchUserToken(ctx context.Context, id uuid.UUID) error
TrimCronExecutions(ctx context.Context, arg TrimCronExecutionsParams) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
@@ -144,16 +347,36 @@ type Querier interface {
// removed replica. $4 is the cumulative rx+tx byte counter.
UpdateContainerStateByName(ctx context.Context, arg UpdateContainerStateByNameParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
UpdateCronJobRun(ctx context.Context, arg UpdateCronJobRunParams) error
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error
UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error
UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
UpsertAdminUser(ctx context.Context, arg UpsertAdminUserParams) (uuid.UUID, error)
UpsertAppSetting(ctx context.Context, arg UpsertAppSettingParams) error
UpsertAppSettingValue(ctx context.Context, arg UpsertAppSettingValueParams) error
UpsertBuild(ctx context.Context, arg UpsertBuildParams) error
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error)
UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error
UpsertGitWebhook(ctx context.Context, arg UpsertGitWebhookParams) (UpsertGitWebhookRow, error)
UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error
UpsertLocalUser(ctx context.Context, arg UpsertLocalUserParams) (UpsertLocalUserRow, error)
UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error
}
var _ Querier = (*Queries)(nil)
@@ -0,0 +1,355 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: runtime.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const getLastDeployedImage = `-- name: GetLastDeployedImage :one
SELECT (image_name || ':' || image_tag)::text AS image FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1
`
func (q *Queries) GetLastDeployedImage(ctx context.Context, serviceID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getLastDeployedImage, serviceID)
var image string
err := row.Scan(&image)
return image, err
}
const getServiceDomainText = `-- name: GetServiceDomainText :one
SELECT COALESCE(domain,'') AS domain FROM services WHERE id = $1
`
func (q *Queries) GetServiceDomainText(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceDomainText, id)
var domain string
err := row.Scan(&domain)
return domain, err
}
const getServicePort = `-- name: GetServicePort :one
SELECT COALESCE(port, 0) AS port FROM services WHERE id = $1
`
func (q *Queries) GetServicePort(ctx context.Context, id uuid.UUID) (int32, error) {
row := q.db.QueryRowContext(ctx, getServicePort, id)
var port int32
err := row.Scan(&port)
return port, err
}
const getServiceRuntimeWithOwner = `-- name: GetServiceRuntimeWithOwner :one
SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
COALESCE(s.static_dir, '') AS static_dir,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1
`
type GetServiceRuntimeWithOwnerRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
Cpu string `json:"cpu"`
Memory string `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error) {
row := q.db.QueryRowContext(ctx, getServiceRuntimeWithOwner, id)
var i GetServiceRuntimeWithOwnerRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Type,
&i.Status,
&i.Image,
&i.Command,
&i.Environment,
&i.GitRepo,
&i.GitBranch,
&i.BuildPath,
&i.Cpu,
&i.Memory,
&i.Replicas,
&i.Port,
&i.Domain,
&i.HealthcheckPath,
&i.RestartPolicy,
&i.Builder,
&i.CpuReserve,
&i.MemoryReserve,
&i.StaticBuildCmd,
&i.StaticDir,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
)
return i, err
}
const getServiceStatusText = `-- name: GetServiceStatusText :one
SELECT COALESCE(status,'') AS status FROM services WHERE id = $1
`
func (q *Queries) GetServiceStatusText(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceStatusText, id)
var status string
err := row.Scan(&status)
return status, err
}
const getServiceVariableValue = `-- name: GetServiceVariableValue :one
SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2
`
type GetServiceVariableValueParams struct {
ServiceID uuid.UUID `json:"service_id"`
Key string `json:"key"`
}
func (q *Queries) GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceVariableValue, arg.ServiceID, arg.Key)
var value string
err := row.Scan(&value)
return value, err
}
const listNodeAgentsForService = `-- name: ListNodeAgentsForService :many
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1
`
func (q *Queries) ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listNodeAgentsForService, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var node_agent_id string
if err := rows.Scan(&node_agent_id); err != nil {
return nil, err
}
items = append(items, node_agent_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectServiceRefs = `-- name: ListProjectServiceRefs :many
SELECT id, name FROM services WHERE project_id = $1
`
type ListProjectServiceRefsRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
func (q *Queries) ListProjectServiceRefs(ctx context.Context, projectID uuid.UUID) ([]ListProjectServiceRefsRow, error) {
rows, err := q.db.QueryContext(ctx, listProjectServiceRefs, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListProjectServiceRefsRow{}
for rows.Next() {
var i ListProjectServiceRefsRow
if err := rows.Scan(&i.ID, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listServiceVariableValues = `-- name: ListServiceVariableValues :many
SELECT key, value FROM environment_variables WHERE service_id = $1
`
type ListServiceVariableValuesRow struct {
Key string `json:"key"`
Value string `json:"value"`
}
func (q *Queries) ListServiceVariableValues(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariableValuesRow, error) {
rows, err := q.db.QueryContext(ctx, listServiceVariableValues, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServiceVariableValuesRow{}
for rows.Next() {
var i ListServiceVariableValuesRow
if err := rows.Scan(&i.Key, &i.Value); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listServiceVariablesWithSecret = `-- name: ListServiceVariablesWithSecret :many
SELECT key, value, COALESCE(is_secret, false) AS is_secret
FROM environment_variables WHERE service_id = $1
`
type ListServiceVariablesWithSecretRow struct {
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
}
func (q *Queries) ListServiceVariablesWithSecret(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariablesWithSecretRow, error) {
rows, err := q.db.QueryContext(ctx, listServiceVariablesWithSecret, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServiceVariablesWithSecretRow{}
for rows.Next() {
var i ListServiceVariablesWithSecretRow
if err := rows.Scan(&i.Key, &i.Value, &i.IsSecret); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSleepCandidates = `-- name: ListSleepCandidates :many
SELECT id, project_id, name, COALESCE(status,'') AS status,
COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
FROM services
WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')
`
type ListSleepCandidatesRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Status string `json:"status"`
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
}
func (q *Queries) ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error) {
rows, err := q.db.QueryContext(ctx, listSleepCandidates)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSleepCandidatesRow{}
for rows.Next() {
var i ListSleepCandidatesRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Status,
&i.SleepIdleMinutes,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setServicePublishedPort = `-- name: SetServicePublishedPort :exec
UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1
`
type SetServicePublishedPortParams struct {
PublishedPort int32 `json:"published_port"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error {
_, err := q.db.ExecContext(ctx, setServicePublishedPort, arg.PublishedPort, arg.ID)
return err
}
const setServiceReplicas = `-- name: SetServiceReplicas :exec
UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3
`
type SetServiceReplicasParams struct {
Replicas int32 `json:"replicas"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error {
_, err := q.db.ExecContext(ctx, setServiceReplicas, arg.Replicas, arg.UpdatedAt, arg.ID)
return err
}
@@ -0,0 +1,318 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: security.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const checkSecurityProjectAccess = `-- name: CheckSecurityProjectAccess :one
SELECT EXISTS (
SELECT 1
FROM projects p
WHERE p.id = $1
AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)
`
type CheckSecurityProjectAccessParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
func (q *Queries) CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error) {
row := q.db.QueryRowContext(ctx, checkSecurityProjectAccess, arg.ID, arg.OwnerID, arg.Column3)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const complianceFrameworkExists = `-- name: ComplianceFrameworkExists :one
SELECT EXISTS(SELECT 1 FROM compliance_frameworks WHERE id = $1)
`
func (q *Queries) ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, complianceFrameworkExists, id)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const getComplianceReportProjectID = `-- name: GetComplianceReportProjectID :one
SELECT project_id FROM compliance_reports WHERE id = $1
`
func (q *Queries) GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getComplianceReportProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getLatestComplianceReport = `-- name: GetLatestComplianceReport :one
SELECT overall_status, score, assessment_date
FROM compliance_reports
WHERE project_id = $1
ORDER BY assessment_date DESC
LIMIT 1
`
type GetLatestComplianceReportRow struct {
OverallStatus string `json:"overall_status"`
Score int32 `json:"score"`
AssessmentDate time.Time `json:"assessment_date"`
}
func (q *Queries) GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error) {
row := q.db.QueryRowContext(ctx, getLatestComplianceReport, projectID)
var i GetLatestComplianceReportRow
err := row.Scan(&i.OverallStatus, &i.Score, &i.AssessmentDate)
return i, err
}
const getLatestSecurityScan = `-- name: GetLatestSecurityScan :one
SELECT id, COALESCE((summary->>'score')::int, 0)::int AS score, started_at AS scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1
`
type GetLatestSecurityScanRow struct {
ID uuid.UUID `json:"id"`
Score int32 `json:"score"`
ScannedAt time.Time `json:"scanned_at"`
Status string `json:"status"`
}
func (q *Queries) GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error) {
row := q.db.QueryRowContext(ctx, getLatestSecurityScan, projectID)
var i GetLatestSecurityScanRow
err := row.Scan(
&i.ID,
&i.Score,
&i.ScannedAt,
&i.Status,
)
return i, err
}
const getProjectOwnerText = `-- name: GetProjectOwnerText :one
SELECT owner_id::text FROM projects WHERE id = $1
`
func (q *Queries) GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getProjectOwnerText, id)
var owner_id string
err := row.Scan(&owner_id)
return owner_id, err
}
const getSecurityScanProjectID = `-- name: GetSecurityScanProjectID :one
SELECT project_id FROM security_scans WHERE id = $1
`
func (q *Queries) GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getSecurityScanProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getVulnerabilityMetrics = `-- name: GetVulnerabilityMetrics :one
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE severity = 'critical') AS critical,
COUNT(*) FILTER (WHERE severity = 'high') AS high,
COUNT(*) FILTER (WHERE severity = 'medium') AS medium,
COUNT(*) FILTER (WHERE severity = 'low') AS low,
COUNT(*) FILTER (WHERE status = 'open') AS open,
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved
FROM vulnerabilities
WHERE project_id = $1
`
type GetVulnerabilityMetricsRow struct {
Total int64 `json:"total"`
Critical int64 `json:"critical"`
High int64 `json:"high"`
Medium int64 `json:"medium"`
Low int64 `json:"low"`
Open int64 `json:"open"`
Resolved int64 `json:"resolved"`
}
func (q *Queries) GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error) {
row := q.db.QueryRowContext(ctx, getVulnerabilityMetrics, projectID)
var i GetVulnerabilityMetricsRow
err := row.Scan(
&i.Total,
&i.Critical,
&i.High,
&i.Medium,
&i.Low,
&i.Open,
&i.Resolved,
)
return i, err
}
const getVulnerabilityProjectID = `-- name: GetVulnerabilityProjectID :one
SELECT project_id FROM vulnerabilities WHERE id = $1
`
func (q *Queries) GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getVulnerabilityProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const listComplianceFrameworks = `-- name: ListComplianceFrameworks :many
SELECT id, name, description, version, enabled, created_at
FROM compliance_frameworks
WHERE enabled = true
ORDER BY name
`
type ListComplianceFrameworksRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error) {
rows, err := q.db.QueryContext(ctx, listComplianceFrameworks)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListComplianceFrameworksRow{}
for rows.Next() {
var i ListComplianceFrameworksRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Version,
&i.Enabled,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listVulnerabilities = `-- name: ListVulnerabilities :many
SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at
FROM vulnerabilities
WHERE project_id = $1
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
found_at DESC
`
type ListVulnerabilitiesRow struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
Title string `json:"title"`
Description sql.NullString `json:"description"`
ServiceID uuid.NullUUID `json:"service_id"`
Status string `json:"status"`
FoundAt time.Time `json:"found_at"`
ResolvedAt sql.NullTime `json:"resolved_at"`
}
func (q *Queries) ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error) {
rows, err := q.db.QueryContext(ctx, listVulnerabilities, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListVulnerabilitiesRow{}
for rows.Next() {
var i ListVulnerabilitiesRow
if err := rows.Scan(
&i.ID,
&i.Type,
&i.Severity,
&i.Title,
&i.Description,
&i.ServiceID,
&i.Status,
&i.FoundAt,
&i.ResolvedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const serviceExistsInProject = `-- name: ServiceExistsInProject :one
SELECT EXISTS(SELECT 1 FROM services WHERE id = $1 AND project_id = $2)
`
type ServiceExistsInProjectParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error) {
row := q.db.QueryRowContext(ctx, serviceExistsInProject, arg.ID, arg.ProjectID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const updateVulnerabilityStatus = `-- name: UpdateVulnerabilityStatus :exec
UPDATE vulnerabilities SET status = $1, resolved_at = $2 WHERE id = $3
`
type UpdateVulnerabilityStatusParams struct {
Status string `json:"status"`
ResolvedAt sql.NullTime `json:"resolved_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error {
_, err := q.db.ExecContext(ctx, updateVulnerabilityStatus, arg.Status, arg.ResolvedAt, arg.ID)
return err
}
@@ -0,0 +1,158 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: service_ops.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const cloneServiceDomains = `-- name: CloneServiceDomains :exec
INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains sd WHERE sd.service_id = $2
`
type CloneServiceDomainsParams struct {
ServiceID uuid.UUID `json:"service_id"`
ServiceID_2 uuid.UUID `json:"service_id_2"`
}
func (q *Queries) CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error {
_, err := q.db.ExecContext(ctx, cloneServiceDomains, arg.ServiceID, arg.ServiceID_2)
return err
}
const cloneServiceRow = `-- name: CloneServiceRow :exec
INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
traefik_labels, created_at, updated_at)
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
build_command, start_command, type, 'stopped', image, command, $5,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, $6, $7,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
traefik_labels, $8, $8
FROM services s WHERE s.id = $9
`
type CloneServiceRowParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
EnvironmentID uuid.UUID `json:"environment_id"`
Environment sql.NullString `json:"environment"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
CreatedAt sql.NullTime `json:"created_at"`
ID_2 uuid.UUID `json:"id_2"`
}
func (q *Queries) CloneServiceRow(ctx context.Context, arg CloneServiceRowParams) error {
_, err := q.db.ExecContext(ctx, cloneServiceRow,
arg.ID,
arg.ProjectID,
arg.Name,
arg.EnvironmentID,
arg.Environment,
arg.MaintenanceMode,
arg.BasicAuthUsers,
arg.CreatedAt,
arg.ID_2,
)
return err
}
const cloneServiceVariables = `-- name: CloneServiceVariables :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables ev WHERE ev.service_id = $2
`
type CloneServiceVariablesParams struct {
ServiceID uuid.UUID `json:"service_id"`
ServiceID_2 uuid.UUID `json:"service_id_2"`
}
func (q *Queries) CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error {
_, err := q.db.ExecContext(ctx, cloneServiceVariables, arg.ServiceID, arg.ServiceID_2)
return err
}
const countServicesByName = `-- name: CountServicesByName :one
SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2
`
type CountServicesByNameParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countServicesByName, arg.ProjectID, arg.Name)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteServiceByID = `-- name: DeleteServiceByID :exec
DELETE FROM services WHERE id = $1
`
func (q *Queries) DeleteServiceByID(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteServiceByID, id)
return err
}
const getProjectOwner = `-- name: GetProjectOwner :one
SELECT owner_id FROM projects WHERE id = $1
`
func (q *Queries) GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getProjectOwner, id)
var owner_id uuid.UUID
err := row.Scan(&owner_id)
return owner_id, err
}
const getServiceEnvironment = `-- name: GetServiceEnvironment :one
SELECT COALESCE(environment, '') FROM services WHERE id = $1
`
func (q *Queries) GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceEnvironment, id)
var environment string
err := row.Scan(&environment)
return environment, err
}
const moveServiceProject = `-- name: MoveServiceProject :execrows
UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4
`
type MoveServiceProjectParams struct {
ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) MoveServiceProject(ctx context.Context, arg MoveServiceProjectParams) (int64, error) {
result, err := q.db.ExecContext(ctx, moveServiceProject,
arg.ProjectID,
arg.EnvironmentID,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
@@ -0,0 +1,308 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: services_meta.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"github.com/google/uuid"
)
const deleteEnvVarsForService = `-- name: DeleteEnvVarsForService :exec
DELETE FROM environment_variables WHERE service_id = $1
`
func (q *Queries) DeleteEnvVarsForService(ctx context.Context, serviceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteEnvVarsForService, serviceID)
return err
}
const failInterruptedDeployments = `-- name: FailInterruptedDeployments :exec
UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')
`
func (q *Queries) FailInterruptedDeployments(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, failInterruptedDeployments)
return err
}
const getEnvironmentProjectID = `-- name: GetEnvironmentProjectID :one
SELECT project_id FROM environments WHERE id = $1
`
func (q *Queries) GetEnvironmentProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getEnvironmentProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getNodeAgentName = `-- name: GetNodeAgentName :one
SELECT name FROM node_agents WHERE id = $1
`
func (q *Queries) GetNodeAgentName(ctx context.Context, id string) (string, error) {
row := q.db.QueryRowContext(ctx, getNodeAgentName, id)
var name string
err := row.Scan(&name)
return name, err
}
const getProjectReadAccess = `-- name: GetProjectReadAccess :one
SELECT is_approved, owner_id::text FROM projects WHERE id = $1
`
type GetProjectReadAccessRow struct {
IsApproved bool `json:"is_approved"`
OwnerID string `json:"owner_id"`
}
func (q *Queries) GetProjectReadAccess(ctx context.Context, id uuid.UUID) (GetProjectReadAccessRow, error) {
row := q.db.QueryRowContext(ctx, getProjectReadAccess, id)
var i GetProjectReadAccessRow
err := row.Scan(&i.IsApproved, &i.OwnerID)
return i, err
}
const getServiceNameProjectPort = `-- name: GetServiceNameProjectPort :one
SELECT name, project_id::text, COALESCE(port, 0)::int AS port
FROM services WHERE id::text = $1
`
type GetServiceNameProjectPortRow struct {
Name string `json:"name"`
ProjectID string `json:"project_id"`
Port int32 `json:"port"`
}
func (q *Queries) GetServiceNameProjectPort(ctx context.Context, id uuid.UUID) (GetServiceNameProjectPortRow, error) {
row := q.db.QueryRowContext(ctx, getServiceNameProjectPort, id)
var i GetServiceNameProjectPortRow
err := row.Scan(&i.Name, &i.ProjectID, &i.Port)
return i, err
}
const getServiceNodeID = `-- name: GetServiceNodeID :one
SELECT node_id FROM services WHERE id = $1
`
func (q *Queries) GetServiceNodeID(ctx context.Context, id uuid.UUID) (sql.NullString, error) {
row := q.db.QueryRowContext(ctx, getServiceNodeID, id)
var node_id sql.NullString
err := row.Scan(&node_id)
return node_id, err
}
const getServicePlacementTags = `-- name: GetServicePlacementTags :one
SELECT placement_tags FROM services WHERE id = $1
`
func (q *Queries) GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
row := q.db.QueryRowContext(ctx, getServicePlacementTags, id)
var placement_tags json.RawMessage
err := row.Scan(&placement_tags)
return placement_tags, err
}
const getServiceProjectAndStatus = `-- name: GetServiceProjectAndStatus :one
SELECT s.project_id, COALESCE(s.status, '') AS status
FROM services s WHERE s.id = $1
`
type GetServiceProjectAndStatusRow struct {
ProjectID uuid.UUID `json:"project_id"`
Status string `json:"status"`
}
func (q *Queries) GetServiceProjectAndStatus(ctx context.Context, id uuid.UUID) (GetServiceProjectAndStatusRow, error) {
row := q.db.QueryRowContext(ctx, getServiceProjectAndStatus, id)
var i GetServiceProjectAndStatusRow
err := row.Scan(&i.ProjectID, &i.Status)
return i, err
}
const getServiceProjectID = `-- name: GetServiceProjectID :one
SELECT project_id FROM services WHERE id = $1
`
func (q *Queries) GetServiceProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getServiceProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getServiceSleep = `-- name: GetServiceSleep :one
SELECT COALESCE(sleep_enabled, false) AS sleep_enabled, COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
FROM services WHERE id = $1
`
type GetServiceSleepRow struct {
SleepEnabled bool `json:"sleep_enabled"`
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
}
func (q *Queries) GetServiceSleep(ctx context.Context, id uuid.UUID) (GetServiceSleepRow, error) {
row := q.db.QueryRowContext(ctx, getServiceSleep, id)
var i GetServiceSleepRow
err := row.Scan(&i.SleepEnabled, &i.SleepIdleMinutes)
return i, err
}
const getServiceSpread = `-- name: GetServiceSpread :one
SELECT COALESCE(spread, false) FROM services WHERE id = $1
`
func (q *Queries) GetServiceSpread(ctx context.Context, id uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, getServiceSpread, id)
var spread bool
err := row.Scan(&spread)
return spread, err
}
const getServiceTraefikLabels = `-- name: GetServiceTraefikLabels :one
SELECT traefik_labels FROM services WHERE id = $1
`
func (q *Queries) GetServiceTraefikLabels(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
row := q.db.QueryRowContext(ctx, getServiceTraefikLabels, id)
var traefik_labels json.RawMessage
err := row.Scan(&traefik_labels)
return traefik_labels, err
}
const getServiceVolumes = `-- name: GetServiceVolumes :one
SELECT volumes FROM services WHERE id = $1
`
func (q *Queries) GetServiceVolumes(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
row := q.db.QueryRowContext(ctx, getServiceVolumes, id)
var volumes json.RawMessage
err := row.Scan(&volumes)
return volumes, err
}
const listAgentHealthRows = `-- name: ListAgentHealthRows :many
SELECT id, COALESCE(name, hostname, id::text) AS name, COALESCE(status, '') AS status,
COALESCE(last_heartbeat < $1, TRUE)::bool AS stale, auto_prune
FROM node_agents
`
type ListAgentHealthRowsRow struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Stale bool `json:"stale"`
AutoPrune bool `json:"auto_prune"`
}
func (q *Queries) ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error) {
rows, err := q.db.QueryContext(ctx, listAgentHealthRows, lastHeartbeat)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAgentHealthRowsRow{}
for rows.Next() {
var i ListAgentHealthRowsRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Status,
&i.Stale,
&i.AutoPrune,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSecretEnvVars = `-- name: ListSecretEnvVars :many
SELECT id, value FROM environment_variables WHERE is_secret = TRUE
`
type ListSecretEnvVarsRow struct {
ID uuid.UUID `json:"id"`
Value string `json:"value"`
}
func (q *Queries) ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error) {
rows, err := q.db.QueryContext(ctx, listSecretEnvVars)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSecretEnvVarsRow{}
for rows.Next() {
var i ListSecretEnvVarsRow
if err := rows.Scan(&i.ID, &i.Value); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const projectMemberExists = `-- name: ProjectMemberExists :one
SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)
`
type ProjectMemberExistsParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) ProjectMemberExists(ctx context.Context, arg ProjectMemberExistsParams) (bool, error) {
row := q.db.QueryRowContext(ctx, projectMemberExists, arg.ProjectID, arg.UserID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const setAgentStatus = `-- name: SetAgentStatus :exec
UPDATE node_agents SET status = $1, updated_at = NOW() WHERE id = $2
`
type SetAgentStatusParams struct {
Status sql.NullString `json:"status"`
ID string `json:"id"`
}
func (q *Queries) SetAgentStatus(ctx context.Context, arg SetAgentStatusParams) error {
_, err := q.db.ExecContext(ctx, setAgentStatus, arg.Status, arg.ID)
return err
}
const updateEnvVarValue = `-- name: UpdateEnvVarValue :exec
UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2
`
type UpdateEnvVarValueParams struct {
Value string `json:"value"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error {
_, err := q.db.ExecContext(ctx, updateEnvVarValue, arg.Value, arg.ID)
return err
}
@@ -0,0 +1,103 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: settings.sql
package sqlcdb
import (
"context"
"github.com/google/uuid"
)
const deleteAppSetting = `-- name: DeleteAppSetting :exec
DELETE FROM app_settings WHERE key = $1
`
func (q *Queries) DeleteAppSetting(ctx context.Context, key string) error {
_, err := q.db.ExecContext(ctx, deleteAppSetting, key)
return err
}
const getAppSetting = `-- name: GetAppSetting :one
SELECT value FROM app_settings WHERE key = $1
`
func (q *Queries) GetAppSetting(ctx context.Context, key string) (string, error) {
row := q.db.QueryRowContext(ctx, getAppSetting, key)
var value string
err := row.Scan(&value)
return value, err
}
const getUserIsAdmin = `-- name: GetUserIsAdmin :one
SELECT is_admin FROM users WHERE id = $1
`
func (q *Queries) GetUserIsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, getUserIsAdmin, id)
var is_admin bool
err := row.Scan(&is_admin)
return is_admin, err
}
const listAdminUserIDs = `-- name: ListAdminUserIDs :many
SELECT id FROM users WHERE is_admin
`
func (q *Queries) ListAdminUserIDs(ctx context.Context) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, listAdminUserIDs)
if err != nil {
return nil, err
}
defer rows.Close()
items := []uuid.UUID{}
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAppSetting = `-- name: UpsertAppSetting :exec
INSERT INTO app_settings (key, value, is_secret, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
`
type UpsertAppSettingParams struct {
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
}
func (q *Queries) UpsertAppSetting(ctx context.Context, arg UpsertAppSettingParams) error {
_, err := q.db.ExecContext(ctx, upsertAppSetting, arg.Key, arg.Value, arg.IsSecret)
return err
}
const upsertAppSettingValue = `-- name: UpsertAppSettingValue :exec
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()
`
type UpsertAppSettingValueParams struct {
Key string `json:"key"`
Value string `json:"value"`
}
func (q *Queries) UpsertAppSettingValue(ctx context.Context, arg UpsertAppSettingValueParams) error {
_, err := q.db.ExecContext(ctx, upsertAppSettingValue, arg.Key, arg.Value)
return err
}
@@ -0,0 +1,60 @@
package deployment
import (
"strings"
"testing"
)
func TestEdgeLabelsCustomMiddlewares(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
TraefikLabels: map[string]string{
"middlewares.rl.ratelimit.average": "100",
"middlewares.rl.ratelimit.period": "1m",
"middlewares.sec.headers.customresponseheaders.X-Frame-Options": "DENY",
},
}
labels := edgeLabels(spec, "containr-edge")
if got := labels["traefik.http.middlewares.svc-01234567-rl.ratelimit.average"]; got != "100" {
t.Fatalf("ratelimit label missing: %v", labels)
}
if got := labels["traefik.http.middlewares.svc-01234567-sec.headers.customresponseheaders.X-Frame-Options"]; got != "DENY" {
t.Fatalf("headers label missing: %v", labels)
}
chain := labels["traefik.http.routers.svc-01234567.middlewares"]
if chain != "svc-01234567-rl,svc-01234567-sec" {
t.Fatalf("middleware chain wrong: %q", chain)
}
}
func TestEdgeLabelsCustomAfterBuiltins(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
BasicAuthUsers: "u:$apr1$x",
TraefikLabels: map[string]string{
"middlewares.rl.ratelimit.average": "10",
},
}
labels := edgeLabels(spec, "containr-edge")
chain := labels["traefik.http.routers.svc-01234567.middlewares"]
if !strings.HasPrefix(chain, "svc-01234567-auth,") || !strings.HasSuffix(chain, "svc-01234567-rl") {
t.Fatalf("builtin should precede custom: %q", chain)
}
}
func TestEdgeLabelsNoCustom(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
}
labels := edgeLabels(spec, "containr-edge")
if _, ok := labels["traefik.http.routers.svc-01234567.middlewares"]; ok {
t.Fatal("middleware chain should be absent with no middlewares")
}
}
+81 -37
View File
@@ -55,6 +55,10 @@ type RuntimeSpec struct {
Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
// TraefikLabels defines extra middlewares for this service's router —
// keys "middlewares.<name>.<type>[.<field>]" (API-validated against an
// allowlist). Defined middlewares attach to the router after builtins.
TraefikLabels map[string]string
// NodeID pins the service to a node agent; "" runs on the local host.
NodeID string
// Spread distributes replicas across all online, schedulable agents
@@ -274,44 +278,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}},
}
if edgeNet != "" {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true"
labels["traefik.docker.network"] = edgeNet
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
labels["traefik.http.routers."+router+".entrypoints"] = "web"
labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
for k, v := range edgeLabels(spec, edgeNet) {
labels[k] = v
}
}
@@ -399,6 +368,81 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
// vpnkit drops a conflicting binding).
var errPortNotBound = errors.New("requested host port not bound")
// edgeLabels builds the Traefik router/service/middleware labels for a
// replica on the edge network: host rules, load balancer port, builtin
// maintenance/basic-auth middlewares, then operator-defined overrides.
func edgeLabels(spec RuntimeSpec, edgeNet string) map[string]string {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
labels := map[string]string{
"traefik.enable": "true",
"traefik.docker.network": edgeNet,
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
"traefik.http.routers." + router + ".entrypoints": "web",
"traefik.http.services." + router + ".loadbalancer.server.port": fmt.Sprintf("%d", spec.Port),
}
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
// Operator-defined middlewares: "middlewares.<name>.<rest>" becomes
// "<router>-<name>.<rest>", and every defined name joins the router's
// chain after builtins. Names sort for a deterministic chain order.
customMWs := map[string][]string{}
for k, v := range spec.TraefikLabels {
rest := strings.TrimPrefix(k, "middlewares.")
dot := strings.Index(rest, ".")
if dot <= 0 {
continue
}
customMWs[rest[:dot]] = append(customMWs[rest[:dot]], rest[dot+1:]+"="+v)
}
mwNames := make([]string, 0, len(customMWs))
for name := range customMWs {
mwNames = append(mwNames, name)
}
sort.Strings(mwNames)
for _, name := range mwNames {
mw := router + "-" + name
sort.Strings(customMWs[name])
for _, kv := range customMWs[name] {
if i := strings.Index(kv, "="); i > 0 {
labels["traefik.http.middlewares."+mw+"."+kv[:i]] = kv[i+1:]
}
}
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
}
return labels
}
// isPortConflict reports whether a container create failed because the
// requested host port was already bound.
func isPortConflict(err error) bool {
@@ -0,0 +1,64 @@
package gateway
import "testing"
func TestBuildTargetURL(t *testing.T) {
cases := []struct {
name string
upstream, prefix, path, query, want string
}{
{
name: "strips route prefix",
upstream: "https://api.internal:8080",
prefix: "/g/weather",
path: "/g/weather/v1/current",
query: "city=oslo",
want: "https://api.internal:8080/v1/current?city=oslo",
},
{
name: "prefix root maps to upstream root",
upstream: "https://api.internal",
prefix: "/g/weather",
path: "/g/weather",
want: "https://api.internal/",
},
{
name: "upstream base path is preserved",
upstream: "https://api.internal/base",
prefix: "/g/x",
path: "/g/x/ping",
want: "https://api.internal/base/ping",
},
{
name: "root prefix leaves path untouched",
upstream: "https://api.internal",
prefix: "/",
path: "/g/raw",
want: "https://api.internal/g/raw",
},
{
name: "path without prefix is still proxied",
upstream: "https://api.internal",
prefix: "/g/x",
path: "/other",
want: "https://api.internal/other",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := BuildTargetURL(tc.upstream, tc.prefix, tc.path, tc.query)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestBuildTargetURL_InvalidUpstream(t *testing.T) {
if _, err := BuildTargetURL("://bad", "/g/x", "/g/x/y", ""); err == nil {
t.Fatal("expected error for unparsable upstream")
}
}
+12 -3
View File
@@ -50,10 +50,15 @@ func init() {
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
// Environments
register(spec{"containr_environments_list", "List environments in a project with service counts", "GET", "/projects/{id}/environments", []string{"id"}, nil, false})
register(spec{"containr_environments_create", "Create an environment. Args: name (required — lowercase alphanumeric with dashes, 1-50 chars).", "POST", "/projects/{id}/environments", []string{"id"}, []string{"name"}, false})
register(spec{"containr_environments_delete", "Delete an empty environment. Requires confirm:true.", "DELETE", "/environments/{id}", []string{"id"}, nil, true})
// Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides), traefik_labels (object — extra Traefik middlewares for this service's router; keys 'middlewares.<name>.<type>[.<field>]', allowlist-validated; defined middlewares attach to the router automatically).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
@@ -66,7 +71,7 @@ func init() {
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first.", "PUT", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first. traefik_labels (object; {} clears) replaces the service's Traefik middleware overrides — keys 'middlewares.<name>.<type>[.<field>]', e.g. {'middlewares.rl.ratelimit.average': '100'}.", "PUT", "/services/{id}", []string{"id"}, nil, false})
// Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
@@ -140,6 +145,10 @@ func init() {
// Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
register(spec{"containr_notification_channels_list", "List push notification channels (ntfy/Gotify)", "GET", "/notifications/channels", nil, nil, false})
register(spec{"containr_notification_channels_create", "Add a push notification channel. Args: kind (ntfy|gotify), endpoint (full URL incl. ntfy topic or Gotify base), token (ntfy access token or Gotify app token).", "POST", "/notifications/channels", nil, []string{"kind", "endpoint", "token"}, false})
register(spec{"containr_notification_channels_test", "Send a test notification through a push channel. Args: id.", "POST", "/notifications/channels/{id}/test", []string{"id"}, nil, false})
register(spec{"containr_notification_channels_delete", "Delete a push notification channel. Args: id. Requires confirm=true.", "DELETE", "/notifications/channels/{id}", []string{"id"}, nil, true})
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
@@ -174,7 +183,7 @@ func init() {
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags"}, false})
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears), default_domain (base domain — services placed on this node with no explicit domains get <name>.<default_domain>; empty clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags", "default_domain"}, false})
register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true})
+259 -332
View File
@@ -4,293 +4,286 @@ import (
"context"
"database/sql"
"fmt"
"strconv"
"sync"
"time"
"containr/internal/database/sqlcdb"
_ "github.com/lib/pq"
)
// PostgreSQLMetricsStorage implements MetricsStorage using PostgreSQL
// through sqlc-generated queries (sqlc/queries/metrics.sql).
//
// Per-container node metrics are deliberately not persisted: the
// container_metrics table belongs to the container_instances model and
// has no place for the node/container samples collected here. Node and
// service/instance rows map cleanly, so those are stored.
type PostgreSQLMetricsStorage struct {
db *sql.DB
q *sqlcdb.Queries
}
// NewPostgreSQLMetricsStorage creates a new PostgreSQL metrics storage
func NewPostgreSQLMetricsStorage(db *sql.DB) *PostgreSQLMetricsStorage {
return &PostgreSQLMetricsStorage{db: db}
return &PostgreSQLMetricsStorage{q: sqlcdb.New(db)}
}
func numStr(v float64) sql.NullString {
return sql.NullString{String: strconv.FormatFloat(v, 'f', -1, 64), Valid: true}
}
func int64N(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: true}
}
func int32N(v int64) sql.NullInt32 {
return sql.NullInt32{Int32: int32(v), Valid: true}
}
func strN(v string) sql.NullString {
return sql.NullString{String: v, Valid: v != ""}
}
func timeN(t time.Time) sql.NullTime {
return sql.NullTime{Time: t, Valid: !t.IsZero()}
}
func f64v(n sql.NullString) float64 {
f, _ := strconv.ParseFloat(n.String, 64)
return f
}
func i64v(n sql.NullInt64) int64 { return n.Int64 }
func i32v(n sql.NullInt32) int64 { return int64(n.Int32) }
// StoreNodeMetrics stores node metrics in the database
func (s *PostgreSQLMetricsStorage) StoreNodeMetrics(ctx context.Context, metrics *NodeMetrics) error {
query := `
INSERT INTO node_metrics (
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)
ON CONFLICT (node_id, timestamp) DO UPDATE SET
cpu_usage = EXCLUDED.cpu_usage,
cpu_cores = EXCLUDED.cpu_cores,
load_avg_1 = EXCLUDED.load_avg_1,
load_avg_5 = EXCLUDED.load_avg_5,
load_avg_15 = EXCLUDED.load_avg_15,
memory_total = EXCLUDED.memory_total,
memory_used = EXCLUDED.memory_used,
memory_available = EXCLUDED.memory_available,
memory_usage_percent = EXCLUDED.memory_usage_percent,
storage_total = EXCLUDED.storage_total,
storage_used = EXCLUDED.storage_used,
storage_available = EXCLUDED.storage_available,
storage_usage_percent = EXCLUDED.storage_usage_percent,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture
`
_, err := s.db.ExecContext(ctx, query,
metrics.NodeID, metrics.Timestamp, metrics.CPU.UsagePercent, metrics.CPU.UsageCores,
metrics.CPU.LoadAverage1, metrics.CPU.LoadAverage5, metrics.CPU.LoadAverage15,
metrics.Memory.Total, metrics.Memory.Used, metrics.Memory.Available, metrics.Memory.UsagePercent,
metrics.Storage.Total, metrics.Storage.Used, metrics.Storage.Available, metrics.Storage.UsagePercent,
metrics.Network.BytesIn, metrics.Network.BytesOut, metrics.Network.PacketsIn, metrics.Network.PacketsOut,
metrics.Network.ConnectionsIn, metrics.Network.ConnectionsOut, metrics.Network.ErrorsIn, metrics.Network.ErrorsOut,
metrics.System.Uptime, metrics.System.Processes, metrics.System.OS, metrics.System.Kernel, metrics.System.Architecture,
)
err := s.q.UpsertNodeMetrics(ctx, sqlcdb.UpsertNodeMetricsParams{
NodeID: metrics.NodeID,
Timestamp: metrics.Timestamp,
CpuUsage: numStr(metrics.CPU.UsagePercent),
CpuCores: numStr(metrics.CPU.UsageCores),
LoadAvg1: numStr(metrics.CPU.LoadAverage1),
LoadAvg5: numStr(metrics.CPU.LoadAverage5),
LoadAvg15: numStr(metrics.CPU.LoadAverage15),
MemoryTotal: int64N(metrics.Memory.Total),
MemoryUsed: int64N(metrics.Memory.Used),
MemoryAvailable: int64N(metrics.Memory.Available),
MemoryUsagePercent: numStr(metrics.Memory.UsagePercent),
StorageTotal: int64N(metrics.Storage.Total),
StorageUsed: int64N(metrics.Storage.Used),
StorageAvailable: int64N(metrics.Storage.Available),
StorageUsagePercent: numStr(metrics.Storage.UsagePercent),
NetworkBytesIn: int64N(metrics.Network.BytesIn),
NetworkBytesOut: int64N(metrics.Network.BytesOut),
NetworkPacketsIn: int64N(metrics.Network.PacketsIn),
NetworkPacketsOut: int64N(metrics.Network.PacketsOut),
NetworkConnectionsIn: int32N(metrics.Network.ConnectionsIn),
NetworkConnectionsOut: int32N(metrics.Network.ConnectionsOut),
NetworkErrorsIn: int64N(metrics.Network.ErrorsIn),
NetworkErrorsOut: int64N(metrics.Network.ErrorsOut),
// INTERVAL param — Postgres casts the bigint as seconds.
Uptime: int64N(int64(metrics.System.Uptime.Seconds())),
Processes: sql.NullInt32{Int32: int32(metrics.System.Processes), Valid: true},
Os: strN(metrics.System.OS),
Kernel: strN(metrics.System.Kernel),
Architecture: strN(metrics.System.Architecture),
})
if err != nil {
return fmt.Errorf("failed to store node metrics: %w", err)
}
// Store container metrics
for _, container := range metrics.Containers {
if err := s.storeContainerMetrics(ctx, metrics.NodeID, metrics.Timestamp, container); err != nil {
return fmt.Errorf("failed to store container metrics: %w", err)
}
}
return nil
}
// StoreServiceMetrics stores service metrics in the database
func (s *PostgreSQLMetricsStorage) StoreServiceMetrics(ctx context.Context, metrics *ServiceMetrics) error {
query := `
INSERT INTO service_metrics (
service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)
ON CONFLICT (service_id, timestamp) DO UPDATE SET
requests_total = EXCLUDED.requests_total,
requests_success = EXCLUDED.requests_success,
requests_errors = EXCLUDED.requests_errors,
requests_avg_latency = EXCLUDED.requests_avg_latency,
requests_p95_latency = EXCLUDED.requests_p95_latency,
requests_p99_latency = EXCLUDED.requests_p99_latency,
requests_throughput = EXCLUDED.requests_throughput,
errors_total = EXCLUDED.errors_total,
errors_rate = EXCLUDED.errors_rate,
performance_response_time = EXCLUDED.performance_response_time,
performance_throughput = EXCLUDED.performance_throughput,
performance_concurrency = EXCLUDED.performance_concurrency,
performance_saturation = EXCLUDED.performance_saturation,
performance_utilization = EXCLUDED.performance_utilization,
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score
`
_, err := s.db.ExecContext(ctx, query,
metrics.ServiceID, metrics.ServiceName, metrics.ProjectID, metrics.Timestamp,
metrics.Requests.Total, metrics.Requests.Success, metrics.Requests.Errors,
metrics.Requests.AvgLatency, metrics.Requests.P95Latency, metrics.Requests.P99Latency,
metrics.Requests.Throughput, metrics.Errors.Total, metrics.Errors.Rate,
metrics.Performance.ResponseTime, metrics.Performance.Throughput,
metrics.Performance.Concurrency, metrics.Performance.Saturation, metrics.Performance.Utilization,
metrics.Resources.CPUUsage, metrics.Resources.MemoryUsage, metrics.Resources.StorageUsage,
metrics.Resources.NetworkUsage, metrics.Resources.ResourceScore,
)
err := s.q.UpsertServiceMetrics(ctx, sqlcdb.UpsertServiceMetricsParams{
ServiceID: metrics.ServiceID,
ServiceName: metrics.ServiceName,
ProjectID: metrics.ProjectID,
Timestamp: metrics.Timestamp,
RequestsTotal: int64N(metrics.Requests.Total),
RequestsSuccess: int64N(metrics.Requests.Success),
RequestsErrors: int64N(metrics.Requests.Errors),
RequestsAvgLatency: numStr(metrics.Requests.AvgLatency),
RequestsP95Latency: numStr(metrics.Requests.P95Latency),
RequestsP99Latency: numStr(metrics.Requests.P99Latency),
RequestsThroughput: numStr(metrics.Requests.Throughput),
ErrorsTotal: int64N(metrics.Errors.Total),
ErrorsRate: numStr(metrics.Errors.Rate),
PerformanceResponseTime: numStr(metrics.Performance.ResponseTime),
PerformanceThroughput: numStr(metrics.Performance.Throughput),
PerformanceConcurrency: int64N(metrics.Performance.Concurrency),
PerformanceSaturation: numStr(metrics.Performance.Saturation),
PerformanceUtilization: numStr(metrics.Performance.Utilization),
ResourceCpuUsage: numStr(metrics.Resources.CPUUsage),
ResourceMemoryUsage: int64N(metrics.Resources.MemoryUsage),
ResourceStorageUsage: int64N(metrics.Resources.StorageUsage),
ResourceNetworkUsage: int64N(metrics.Resources.NetworkUsage),
ResourceScore: numStr(metrics.Resources.ResourceScore),
})
if err != nil {
return fmt.Errorf("failed to store service metrics: %w", err)
}
// Store instance metrics
// The service row must land first — instance_metrics references
// service_metrics(service_id, timestamp).
for _, instance := range metrics.Instances {
if err := s.storeInstanceMetrics(ctx, metrics.ServiceID, metrics.Timestamp, instance); err != nil {
return fmt.Errorf("failed to store instance metrics: %w", err)
}
}
return nil
}
// GetNodeMetrics retrieves node metrics from the database
func (s *PostgreSQLMetricsStorage) GetNodeMetrics(ctx context.Context, nodeID string, from, to time.Time) ([]*NodeMetrics, error) {
query := `
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
rows, err := s.db.QueryContext(ctx, query, nodeID, from, to)
rows, err := s.q.ListNodeMetrics(ctx, sqlcdb.ListNodeMetricsParams{
NodeID: nodeID,
Timestamp: from,
Timestamp_2: to,
})
if err != nil {
return nil, fmt.Errorf("failed to query node metrics: %w", err)
}
defer rows.Close()
var metrics []*NodeMetrics
for rows.Next() {
var m NodeMetrics
err := rows.Scan(
&m.NodeID, &m.Timestamp, &m.CPU.UsagePercent, &m.CPU.UsageCores,
&m.CPU.LoadAverage1, &m.CPU.LoadAverage5, &m.CPU.LoadAverage15,
&m.Memory.Total, &m.Memory.Used, &m.Memory.Available, &m.Memory.UsagePercent,
&m.Storage.Total, &m.Storage.Used, &m.Storage.Available, &m.Storage.UsagePercent,
&m.Network.BytesIn, &m.Network.BytesOut, &m.Network.PacketsIn, &m.Network.PacketsOut,
&m.Network.ConnectionsIn, &m.Network.ConnectionsOut, &m.Network.ErrorsIn, &m.Network.ErrorsOut,
&m.System.Uptime, &m.System.Processes, &m.System.OS, &m.System.Kernel, &m.System.Architecture,
)
if err != nil {
return nil, fmt.Errorf("failed to scan node metrics: %w", err)
}
// Get container metrics for this timestamp
containers, err := s.getContainerMetrics(ctx, nodeID, m.Timestamp)
if err != nil {
return nil, fmt.Errorf("failed to get container metrics: %w", err)
}
m.Containers = containers
metrics = append(metrics, &m)
for _, r := range rows {
metrics = append(metrics, &NodeMetrics{
NodeID: r.NodeID,
Timestamp: r.Timestamp,
CPU: CPUMetrics{
UsagePercent: f64v(r.CpuUsage),
UsageCores: f64v(r.CpuCores),
LoadAverage1: f64v(r.LoadAvg1),
LoadAverage5: f64v(r.LoadAvg5),
LoadAverage15: f64v(r.LoadAvg15),
},
Memory: MemoryMetrics{
Total: i64v(r.MemoryTotal),
Used: i64v(r.MemoryUsed),
Available: i64v(r.MemoryAvailable),
UsagePercent: f64v(r.MemoryUsagePercent),
},
Storage: StorageMetrics{
Total: i64v(r.StorageTotal),
Used: i64v(r.StorageUsed),
Available: i64v(r.StorageAvailable),
UsagePercent: f64v(r.StorageUsagePercent),
},
Network: NetworkMetrics{
BytesIn: i64v(r.NetworkBytesIn),
BytesOut: i64v(r.NetworkBytesOut),
PacketsIn: i64v(r.NetworkPacketsIn),
PacketsOut: i64v(r.NetworkPacketsOut),
ConnectionsIn: i32v(r.NetworkConnectionsIn),
ConnectionsOut: i32v(r.NetworkConnectionsOut),
ErrorsIn: i64v(r.NetworkErrorsIn),
ErrorsOut: i64v(r.NetworkErrorsOut),
},
System: SystemMetrics{
Uptime: time.Duration(r.UptimeSeconds) * time.Second,
Processes: int(r.Processes.Int32),
OS: r.Os.String,
Kernel: r.Kernel.String,
Architecture: r.Architecture.String,
},
})
}
return metrics, nil
}
// GetServiceMetrics retrieves service metrics from the database
func (s *PostgreSQLMetricsStorage) GetServiceMetrics(ctx context.Context, serviceID string, from, to time.Time) ([]*ServiceMetrics, error) {
query := `
SELECT service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
rows, err := s.db.QueryContext(ctx, query, serviceID, from, to)
rows, err := s.q.ListServiceMetrics(ctx, sqlcdb.ListServiceMetricsParams{
ServiceID: serviceID,
Timestamp: from,
Timestamp_2: to,
})
if err != nil {
return nil, fmt.Errorf("failed to query service metrics: %w", err)
}
defer rows.Close()
var metrics []*ServiceMetrics
for rows.Next() {
var m ServiceMetrics
err := rows.Scan(
&m.ServiceID, &m.ServiceName, &m.ProjectID, &m.Timestamp,
&m.Requests.Total, &m.Requests.Success, &m.Requests.Errors,
&m.Requests.AvgLatency, &m.Requests.P95Latency, &m.Requests.P99Latency,
&m.Requests.Throughput, &m.Errors.Total, &m.Errors.Rate,
&m.Performance.ResponseTime, &m.Performance.Throughput,
&m.Performance.Concurrency, &m.Performance.Saturation, &m.Performance.Utilization,
&m.Resources.CPUUsage, &m.Resources.MemoryUsage, &m.Resources.StorageUsage,
&m.Resources.NetworkUsage, &m.Resources.ResourceScore,
)
if err != nil {
return nil, fmt.Errorf("failed to scan service metrics: %w", err)
for _, r := range rows {
m := &ServiceMetrics{
ServiceID: r.ServiceID,
ServiceName: r.ServiceName,
ProjectID: r.ProjectID,
Timestamp: r.Timestamp,
Requests: RequestMetrics{
Total: i64v(r.RequestsTotal),
Success: i64v(r.RequestsSuccess),
Errors: i64v(r.RequestsErrors),
AvgLatency: f64v(r.RequestsAvgLatency),
P95Latency: f64v(r.RequestsP95Latency),
P99Latency: f64v(r.RequestsP99Latency),
Throughput: f64v(r.RequestsThroughput),
},
Errors: ErrorMetrics{
Total: i64v(r.ErrorsTotal),
Rate: f64v(r.ErrorsRate),
},
Performance: PerformanceMetrics{
ResponseTime: f64v(r.PerformanceResponseTime),
Throughput: f64v(r.PerformanceThroughput),
Concurrency: i64v(r.PerformanceConcurrency),
Saturation: f64v(r.PerformanceSaturation),
Utilization: f64v(r.PerformanceUtilization),
},
Resources: ResourceMetrics{
CPUUsage: f64v(r.ResourceCpuUsage),
MemoryUsage: i64v(r.ResourceMemoryUsage),
StorageUsage: i64v(r.ResourceStorageUsage),
NetworkUsage: i64v(r.ResourceNetworkUsage),
ResourceScore: f64v(r.ResourceScore),
},
}
// Get instance metrics for this timestamp
instances, err := s.getInstanceMetrics(ctx, serviceID, m.Timestamp)
instances, err := s.getInstanceMetrics(ctx, serviceID, r.Timestamp)
if err != nil {
return nil, fmt.Errorf("failed to get instance metrics: %w", err)
}
m.Instances = instances
metrics = append(metrics, &m)
metrics = append(metrics, m)
}
return metrics, nil
}
// GetAggregatedMetrics retrieves aggregated metrics based on a query
// GetAggregatedMetrics retrieves aggregated metrics based on a query.
// Bucketing uses date_bin (PostgreSQL 14+) — no TimescaleDB needed.
func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
// This is a simplified implementation
// In a real system, you'd build dynamic SQL based on the query
var timeSeries []TimeSeriesPoint
var summary map[string]MetricSummary
switch query.Type {
case "node":
// Aggregate node metrics
nodeQuery := `
SELECT
time_bucket($1, timestamp) AS bucket,
AVG(cpu_usage) as avg_cpu,
AVG(memory_usage_percent) as avg_memory,
AVG(storage_usage_percent) as avg_storage
FROM node_metrics
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4
GROUP BY bucket
ORDER BY bucket ASC
`
rows, err := s.db.QueryContext(ctx, nodeQuery, query.Interval, query.ID, query.From, query.To)
seconds := int64(query.Interval.Seconds())
if seconds <= 0 {
seconds = 300
}
rows, err := s.q.AggregateNodeMetrics(ctx, sqlcdb.AggregateNodeMetricsParams{
Column1: seconds,
NodeID: query.ID,
Timestamp: query.From,
Timestamp_2: query.To,
})
if err != nil {
return nil, fmt.Errorf("failed to query aggregated node metrics: %w", err)
}
defer rows.Close()
for rows.Next() {
var bucket time.Time
var avgCPU, avgMemory, avgStorage float64
if err := rows.Scan(&bucket, &avgCPU, &avgMemory, &avgStorage); err != nil {
return nil, fmt.Errorf("failed to scan aggregated metrics: %w", err)
}
point := TimeSeriesPoint{
for _, r := range rows {
bucket, _ := r.Bucket.(time.Time)
timeSeries = append(timeSeries, TimeSeriesPoint{
Timestamp: bucket,
Values: map[string]float64{
"cpu_usage": avgCPU,
"memory_usage": avgMemory,
"storage_usage": avgStorage,
"cpu_usage": r.AvgCpu,
"memory_usage": r.AvgMemory,
"storage_usage": r.AvgStorage,
},
}
timeSeries = append(timeSeries, point)
})
}
// Calculate summary statistics
summary = map[string]MetricSummary{
"cpu_usage": calculateSummary(timeSeries, "cpu_usage"),
"memory_usage": calculateSummary(timeSeries, "memory_usage"),
@@ -305,137 +298,69 @@ func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, que
}, nil
}
// Helper methods
func (s *PostgreSQLMetricsStorage) storeContainerMetrics(ctx context.Context, nodeID string, timestamp time.Time, container ContainerMetrics) error {
query := `
INSERT INTO container_metrics (
node_id, timestamp, container_id, name, state, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, start_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (node_id, timestamp, container_id) DO UPDATE SET
name = EXCLUDED.name,
state = EXCLUDED.state,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
start_time = EXCLUDED.start_time
`
_, err := s.db.ExecContext(ctx, query,
nodeID, timestamp, container.ContainerID, container.Name, container.State,
container.CPU, container.Memory, container.Network.BytesIn, container.Network.BytesOut,
container.Network.PacketsIn, container.Network.PacketsOut, container.StartTime,
)
return err
}
func (s *PostgreSQLMetricsStorage) storeInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time, instance InstanceMetrics) error {
query := `
INSERT INTO instance_metrics (
service_id, timestamp, instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET
node_id = EXCLUDED.node_id,
status = EXCLUDED.status,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
start_time = EXCLUDED.start_time,
last_seen = EXCLUDED.last_seen,
health_status = EXCLUDED.health_status,
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count
`
_, err := s.db.ExecContext(ctx, query,
serviceID, timestamp, instance.InstanceID, instance.NodeID, instance.Status,
instance.CPU, instance.Memory, instance.Network.BytesIn, instance.Network.BytesOut,
instance.Network.PacketsIn, instance.Network.PacketsOut, instance.Network.ConnectionsIn,
instance.Network.ConnectionsOut, instance.Network.ErrorsIn, instance.Network.ErrorsOut,
instance.StartTime, instance.LastSeen, instance.Health.Status, instance.Health.LastCheck,
instance.Health.CheckCount, instance.Health.FailureCount,
)
return err
}
func (s *PostgreSQLMetricsStorage) getContainerMetrics(ctx context.Context, nodeID string, timestamp time.Time) ([]ContainerMetrics, error) {
query := `
SELECT container_id, name, state, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, start_time
FROM container_metrics
WHERE node_id = $1 AND timestamp = $2
`
rows, err := s.db.QueryContext(ctx, query, nodeID, timestamp)
if err != nil {
return nil, err
}
defer rows.Close()
var containers []ContainerMetrics
for rows.Next() {
var c ContainerMetrics
err := rows.Scan(
&c.ContainerID, &c.Name, &c.State, &c.CPU, &c.Memory,
&c.Network.BytesIn, &c.Network.BytesOut, &c.Network.PacketsIn, &c.Network.PacketsOut, &c.StartTime,
)
if err != nil {
return nil, err
}
containers = append(containers, c)
}
return containers, nil
return s.q.UpsertInstanceMetrics(ctx, sqlcdb.UpsertInstanceMetricsParams{
ServiceID: serviceID,
Timestamp: timestamp,
InstanceID: instance.InstanceID,
NodeID: strN(instance.NodeID),
Status: strN(instance.Status),
Cpu: numStr(instance.CPU),
Memory: int64N(instance.Memory),
NetworkBytesIn: int64N(instance.Network.BytesIn),
NetworkBytesOut: int64N(instance.Network.BytesOut),
NetworkPacketsIn: int64N(instance.Network.PacketsIn),
NetworkPacketsOut: int64N(instance.Network.PacketsOut),
NetworkConnectionsIn: int32N(instance.Network.ConnectionsIn),
NetworkConnectionsOut: int32N(instance.Network.ConnectionsOut),
NetworkErrorsIn: int64N(instance.Network.ErrorsIn),
NetworkErrorsOut: int64N(instance.Network.ErrorsOut),
StartTime: timeN(instance.StartTime),
LastSeen: timeN(instance.LastSeen),
HealthStatus: strN(instance.Health.Status),
HealthLastCheck: timeN(instance.Health.LastCheck),
HealthCheckCount: sql.NullInt32{Int32: int32(instance.Health.CheckCount), Valid: true},
HealthFailureCount: sql.NullInt32{Int32: int32(instance.Health.FailureCount), Valid: true},
})
}
func (s *PostgreSQLMetricsStorage) getInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time) ([]InstanceMetrics, error) {
query := `
SELECT instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2
`
rows, err := s.db.QueryContext(ctx, query, serviceID, timestamp)
rows, err := s.q.ListInstanceMetrics(ctx, sqlcdb.ListInstanceMetricsParams{
ServiceID: serviceID,
Timestamp: timestamp,
})
if err != nil {
return nil, err
}
defer rows.Close()
var instances []InstanceMetrics
for rows.Next() {
var i InstanceMetrics
err := rows.Scan(
&i.InstanceID, &i.NodeID, &i.Status, &i.CPU, &i.Memory,
&i.Network.BytesIn, &i.Network.BytesOut, &i.Network.PacketsIn, &i.Network.PacketsOut,
&i.Network.ConnectionsIn, &i.Network.ConnectionsOut, &i.Network.ErrorsIn, &i.Network.ErrorsOut,
&i.StartTime, &i.LastSeen, &i.Health.Status, &i.Health.LastCheck, &i.Health.CheckCount, &i.Health.FailureCount,
)
if err != nil {
return nil, err
}
instances = append(instances, i)
for _, r := range rows {
instances = append(instances, InstanceMetrics{
InstanceID: r.InstanceID,
NodeID: r.NodeID.String,
Status: r.Status.String,
CPU: f64v(r.Cpu),
Memory: i64v(r.Memory),
Network: NetworkMetrics{
BytesIn: i64v(r.NetworkBytesIn),
BytesOut: i64v(r.NetworkBytesOut),
PacketsIn: i64v(r.NetworkPacketsIn),
PacketsOut: i64v(r.NetworkPacketsOut),
ConnectionsIn: i32v(r.NetworkConnectionsIn),
ConnectionsOut: i32v(r.NetworkConnectionsOut),
ErrorsIn: i64v(r.NetworkErrorsIn),
ErrorsOut: i64v(r.NetworkErrorsOut),
},
StartTime: r.StartTime.Time,
LastSeen: r.LastSeen.Time,
Health: HealthMetrics{
Status: r.HealthStatus.String,
LastCheck: r.HealthLastCheck.Time,
CheckCount: int(r.HealthCheckCount.Int32),
FailureCount: int(r.HealthFailureCount.Int32),
},
})
}
return instances, nil
}
@@ -544,7 +469,9 @@ func (s *InMemoryMetricsStorage) GetServiceMetrics(ctx context.Context, serviceI
}
func (s *InMemoryMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
// Simplified implementation
s.mu.RLock()
defer s.mu.RUnlock()
return &AggregatedMetrics{
Query: query,
TimeSeries: []TimeSeriesPoint{},
@@ -0,0 +1,130 @@
package metrics
import (
"context"
"database/sql"
"os"
"testing"
"time"
)
// Live round-trip against a real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL) to enable. Skipped by default so unit runs stay hermetic.
func testDSN(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("METRICS_TEST_DSN")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
t.Skip("METRICS_TEST_DSN not set")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.PingContext(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestPostgresStorageRoundTrip(t *testing.T) {
db := testDSN(t)
s := NewPostgreSQLMetricsStorage(db)
ctx := context.Background()
ts := time.Now().UTC().Truncate(time.Second)
nodeID := "test-node-" + ts.Format("150405")
svcID := "test-svc-" + ts.Format("150405")
// Clean slate + teardown
db.ExecContext(ctx, `DELETE FROM instance_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM service_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM node_metrics WHERE node_id = $1`, nodeID)
t.Cleanup(func() {
db.ExecContext(ctx, `DELETE FROM instance_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM service_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM node_metrics WHERE node_id = $1`, nodeID)
})
node := &NodeMetrics{
NodeID: nodeID,
Timestamp: ts,
CPU: CPUMetrics{UsagePercent: 42.5, UsageCores: 1.7, LoadAverage1: 0.5, LoadAverage5: 0.4, LoadAverage15: 0.3},
Memory: MemoryMetrics{Total: 16 << 30, Used: 8 << 30, Available: 8 << 30, UsagePercent: 50.0},
Storage: StorageMetrics{Total: 100 << 30, Used: 40 << 30, Available: 60 << 30, UsagePercent: 40.0},
Network: NetworkMetrics{BytesIn: 1000, BytesOut: 2000, PacketsIn: 10, PacketsOut: 20, ConnectionsIn: 3, ConnectionsOut: 4, ErrorsIn: 0, ErrorsOut: 1},
System: SystemMetrics{Uptime: 3600 * time.Second, Processes: 120, OS: "linux", Kernel: "6.8", Architecture: "amd64"},
}
if err := s.StoreNodeMetrics(ctx, node); err != nil {
t.Fatalf("store node: %v", err)
}
// Upsert idempotent on the same key.
if err := s.StoreNodeMetrics(ctx, node); err != nil {
t.Fatalf("store node (repeat): %v", err)
}
svc := &ServiceMetrics{
ServiceID: svcID,
ServiceName: "svc",
ProjectID: "proj",
Timestamp: ts,
Requests: RequestMetrics{Total: 100, Success: 95, Errors: 5, AvgLatency: 12.5, P95Latency: 80, P99Latency: 120, Throughput: 3.3},
Errors: ErrorMetrics{Total: 5, Rate: 0.05},
Performance: PerformanceMetrics{ResponseTime: 12.5, Throughput: 3.3, Concurrency: 2, Saturation: 0.4, Utilization: 0.55},
Resources: ResourceMetrics{CPUUsage: 42.5, MemoryUsage: 512 << 20, StorageUsage: 1 << 30, NetworkUsage: 3000, ResourceScore: 0.7},
Instances: []InstanceMetrics{{
InstanceID: "inst-1",
NodeID: nodeID,
Status: "running",
CPU: 42.5,
Memory: 512 << 20,
Network: NetworkMetrics{BytesIn: 1000, BytesOut: 2000},
StartTime: ts.Add(-time.Hour),
LastSeen: ts,
Health: HealthMetrics{Status: "healthy", LastCheck: ts, CheckCount: 10, FailureCount: 0},
}},
}
if err := s.StoreServiceMetrics(ctx, svc); err != nil {
t.Fatalf("store service: %v", err)
}
from, to := ts.Add(-time.Minute), ts.Add(time.Minute)
gotNodes, err := s.GetNodeMetrics(ctx, nodeID, from, to)
if err != nil {
t.Fatalf("get nodes: %v", err)
}
if len(gotNodes) != 1 {
t.Fatalf("want 1 node row, got %d", len(gotNodes))
}
if got := gotNodes[0]; got.CPU.UsagePercent != 42.5 || got.System.Processes != 120 || got.System.Uptime != time.Hour {
t.Errorf("node round-trip mismatch: %+v", got)
}
gotSvcs, err := s.GetServiceMetrics(ctx, svcID, from, to)
if err != nil {
t.Fatalf("get services: %v", err)
}
if len(gotSvcs) != 1 {
t.Fatalf("want 1 service row, got %d", len(gotSvcs))
}
if gotSvcs[0].Requests.P99Latency != 120 {
t.Errorf("p99 mismatch: %v", gotSvcs[0].Requests.P99Latency)
}
if len(gotSvcs[0].Instances) != 1 || gotSvcs[0].Instances[0].Health.Status != "healthy" {
t.Errorf("instance join mismatch: %+v", gotSvcs[0].Instances)
}
agg, err := s.GetAggregatedMetrics(ctx, MetricsQuery{Type: "node", ID: nodeID, From: from, To: to, Interval: 5 * time.Minute})
if err != nil {
t.Fatalf("aggregate: %v", err)
}
if len(agg.TimeSeries) != 1 || agg.TimeSeries[0].Values["cpu_usage"] != 42.5 {
t.Errorf("aggregate mismatch: %+v", agg.TimeSeries)
}
if agg.Summary["cpu_usage"].Avg != 42.5 {
t.Errorf("summary mismatch: %+v", agg.Summary)
}
}
@@ -0,0 +1,9 @@
-- +goose Up
-- Routing overrides: per-service Traefik middleware definitions stored as
-- { "middlewares.<name>.<type>.<field>": "value" }. Validated against an
-- allowlist; applied to the service's router and auto-attached to its
-- middleware chain at reconcile time.
ALTER TABLE services ADD COLUMN IF NOT EXISTS traefik_labels jsonb NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE services DROP COLUMN IF EXISTS traefik_labels;
@@ -0,0 +1,8 @@
-- +goose Up
-- Per-node base domain: services pinned/spread onto an agent with
-- default_domain get auto-generated hostnames (<service>.<domain>) when
-- they have no explicit service_domains rows.
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS default_domain VARCHAR(255) NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE node_agents DROP COLUMN IF EXISTS default_domain;
@@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE public.preview_environments
ADD COLUMN IF NOT EXISTS preview_service_id uuid REFERENCES public.services(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_preview_environments_expiry
ON public.preview_environments (expires_at)
WHERE status NOT IN ('expired', 'stopped');
-- +goose Down
DROP INDEX IF EXISTS public.idx_preview_environments_expiry;
ALTER TABLE public.preview_environments DROP COLUMN IF EXISTS preview_service_id;
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS public.notification_channels (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
kind varchar(20) NOT NULL CHECK (kind IN ('ntfy', 'gotify')),
endpoint text NOT NULL,
token varchar(255),
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notification_channels_user ON public.notification_channels (user_id);
-- +goose Down
DROP TABLE IF EXISTS public.notification_channels;
+12
View File
@@ -179,6 +179,18 @@ LIMIT 1;
-- name: SetAgentTags :exec
UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1;
-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1;
-- name: ListSchedulableAgentDomains :many
-- Base domains of every online, schedulable agent carrying the required
-- placement tags — auto-domain candidates for spread services.
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain;
-- name: ListServiceContainers :many
SELECT id, name, node_agent_id, status, ports, resources FROM container_instances
WHERE service_id = $1;
+125
View File
@@ -0,0 +1,125 @@
-- name: GetAPServiceForValidate :one
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1;
-- name: SetAPServiceValidation :exec
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3;
-- name: InsertServiceIncident :exec
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW());
-- name: InsertGatewayIncident :exec
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW());
-- name: ListAPServices :many
SELECT id, name, slug, upstream_url, route_prefix, enabled,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_services
ORDER BY created_at DESC;
-- name: InsertAPService :one
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id;
-- name: SetAPServiceEnabled :exec
UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2;
-- name: ListAPKeys :many
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_keys
ORDER BY created_at DESC;
-- name: InsertAPKey :one
INSERT INTO api_keys (
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id;
-- name: SetAPKeyEnabled :exec
UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2;
-- name: CountAPServices :one
SELECT COUNT(*) FROM api_services;
-- name: CountAPKeys :one
SELECT COUNT(*) FROM api_keys;
-- name: SumUsageCounters :one
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters;
-- name: SumRequestMetricSince :one
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC($1::text, NOW());
-- name: ListTopAPServices :many
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
FROM api_services s
LEFT JOIN usage_counters u ON u.service_id = s.id
GROUP BY s.id, s.name
ORDER BY total_requests DESC, s.name ASC
LIMIT 10;
-- name: ListRequestsByDay :many
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', occurred_at)
ORDER BY DATE_TRUNC('day', occurred_at) ASC;
-- name: ListIncidentStatusCodes :many
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
FROM incident_events
WHERE occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE(http_status, 0)
ORDER BY total DESC, status_code ASC;
-- name: ListClientEventPaths :many
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
ORDER BY total DESC, path ASC
LIMIT 20;
-- name: GetAPServiceBySlug :one
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1;
-- name: ListAPKeysByPrefix :many
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1;
-- name: SumUsageByKey :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE api_key_id = $1 AND period_month = $2;
-- name: SumUsageByService :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE service_id = $1 AND period_month = $2;
-- name: UpsertUsageCounter :exec
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW();
-- name: TouchAPKeyLastUsed :exec
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1;
-- name: InsertMetricPoint :exec
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW());
+28
View File
@@ -0,0 +1,28 @@
-- name: InsertAuditLog :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
-- name: InsertAuditLogWithRequest :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::inet, $8, $9, $10, $11, $12);
-- name: PruneAuditLogs :execrows
DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day');
-- name: ListAuditBackfillBatch :many
SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500;
-- name: SetAuditLogMetadata :exec
UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid;
-- name: ListResourceAuditLogs :many
SELECT id, COALESCE(user_id::text, '')::text AS user_id, resource,
COALESCE(resource_id::text, '')::text AS resource_id, action,
COALESCE(details::text, '{}')::text AS details,
COALESCE(ip_address::text, '')::text AS ip_address,
COALESCE(user_agent, '') AS user_agent, created_at
FROM audit_logs
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100;
+57
View File
@@ -0,0 +1,57 @@
-- name: GetUserByEmailForAuth :one
SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE email = $1;
-- name: CountUsers :one
SELECT COUNT(*) FROM users;
-- name: CountUsersByEmail :one
SELECT COUNT(*) FROM users WHERE email = $1;
-- name: UpsertLocalUser :one
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4::varchar, ''), $5)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at;
-- name: InsertUserAdmin :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at;
-- name: InsertUser :one
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at;
-- name: GetUserByID :one
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE id = $1;
-- name: UpdateUserProfile :exec
UPDATE users
SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url)
WHERE id = $3;
-- name: UpsertAdminUser :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id;
-- name: ListAdminUsers :many
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin,
COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')::text AS created_at
FROM users
ORDER BY created_at ASC;
-- name: SetUserAdmin :execrows
UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2;
-- name: GetAdminUserInfo :one
SELECT email, COALESCE(name, '') AS name, is_admin
FROM users WHERE id = $1;
+33
View File
@@ -0,0 +1,33 @@
-- name: UpsertBuild :exec
INSERT INTO builds
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
ON CONFLICT (id) DO UPDATE SET
project_id = EXCLUDED.project_id,
service_id = EXCLUDED.service_id,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
image_name = EXCLUDED.image_name,
image_tag = EXCLUDED.image_tag,
size = EXCLUDED.size,
error = EXCLUDED.error,
log = EXCLUDED.log,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at;
-- name: GetBuild :one
SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
FROM builds
WHERE id = $1;
-- name: CancelBuild :execrows
UPDATE builds
SET status = 'cancelled',
progress = 100,
completed_at = $1,
log = COALESCE(log, '') || $2,
updated_at = $1
WHERE id = $3
AND status NOT IN ('success', 'failed', 'cancelled');
+58
View File
@@ -0,0 +1,58 @@
-- name: GetCronJobProjectID :one
SELECT project_id FROM cron_jobs WHERE id = $1;
-- name: GetCronOwnerViaService :one
SELECT p.owner_id FROM projects p
JOIN services s ON s.project_id = p.id
WHERE s.id = $1;
-- name: InsertCronJob :exec
INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
-- name: GetCronJob :one
SELECT id, project_id, service_id, name, schedule, command, timezone,
enabled, last_run_at, next_run_at, last_status, last_output,
retention, created_at, updated_at
FROM cron_jobs WHERE id = $1;
-- name: GetCronJobOwner :one
SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1;
-- name: DeleteCronJob :exec
DELETE FROM cron_jobs WHERE id = $1;
-- name: ListCronExecutions :many
SELECT id, cron_job_id, started_at, finished_at, status, output, error
FROM cron_executions
WHERE cron_job_id = $1
ORDER BY started_at DESC
LIMIT 100;
-- name: GetCronJobForTrigger :one
SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1;
-- name: InsertCronExecution :exec
INSERT INTO cron_executions (id, cron_job_id, started_at, status)
VALUES ($1, $2, $3, $4);
-- name: ListDueCronJobs :many
SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
FROM cron_jobs
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW();
-- name: FinishCronExecution :exec
UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5;
-- name: UpdateCronJobRun :exec
UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5;
-- name: TrimCronExecutions :exec
DELETE FROM cron_executions WHERE cron_executions.cron_job_id = $1 AND cron_executions.id NOT IN (
SELECT ce.id FROM cron_executions ce WHERE ce.cron_job_id = $1
ORDER BY ce.started_at DESC LIMIT $2);
+105
View File
@@ -0,0 +1,105 @@
-- name: CountDeploymentsForService :one
SELECT COUNT(*) FROM deployments WHERE service_id = $1;
-- name: ListDeploymentsForService :many
SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: CountAccessibleDeployments :one
SELECT COUNT(*)
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1);
-- name: ListRecentAccessibleDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image_name, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC
LIMIT $3 OFFSET $4;
-- name: GetServiceForDeployWithOwner :one
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
COALESCE(s.static_dir, '') AS static_dir,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1;
-- name: InsertDeployment :exec
INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
-- name: FailDeployment :exec
UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3;
-- name: CompleteDeployment :exec
UPDATE deployments
SET status = $1, error = $2, completed_at = $3, updated_at = $3
WHERE id = $4;
-- name: SetDeploymentStatus :exec
UPDATE deployments SET status = $1, updated_at = $2 WHERE id = $3;
-- name: SetServiceStatus :exec
UPDATE services SET status = $1, updated_at = $2 WHERE id = $3;
-- name: SyncDeploymentProgress :exec
UPDATE deployments
SET status = $1,
image_name = $2,
image_tag = $3,
build_log = $4,
runtime_log = $5,
error = $6,
started_at = $7,
completed_at = $8,
updated_at = $9
WHERE id = $10;
-- name: GetDeploymentAccess :one
SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1;
-- name: GetDeploymentWithProject :one
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.id AS project_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1;
-- name: GetDeploymentForRollback :one
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1;
-- name: GetServicePublishedPort :one
SELECT published_port FROM services WHERE id = $1;
+46
View File
@@ -0,0 +1,46 @@
-- name: ListServiceDomains :many
SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains
WHERE service_id = $1
ORDER BY is_default DESC, created_at ASC;
-- name: CountServiceDomains :one
SELECT COUNT(*) FROM service_domains WHERE service_id = $1;
-- name: ClearServiceDomainDefault :exec
UPDATE service_domains SET is_default = false WHERE service_id = $1;
-- name: CreateServiceDomain :one
INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at;
-- name: DeleteServiceDomain :execrows
DELETE FROM service_domains WHERE id = $1 AND service_id = $2;
-- name: SetServiceDomainDefault :execrows
UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2;
-- name: TouchServiceDomainChecked :exec
UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1;
-- name: GetServiceDomainDefault :one
SELECT domain FROM service_domains WHERE service_id = $1 AND is_default;
-- name: GetServiceDomainFirst :one
SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1;
-- name: SetServiceLegacyDomain :exec
UPDATE services SET domain = $1 WHERE id = $2;
-- name: GetServicePlacementBrief :one
SELECT name, node_id, COALESCE(spread, false) AS spread FROM services WHERE id = $1;
-- name: GetServiceAccess :one
SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1;
-- name: ServiceWriteAccess :one
SELECT EXISTS(
SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)
) AS allowed;
+137
View File
@@ -0,0 +1,137 @@
-- name: ListGitProviders :many
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC;
-- name: UpsertGitHubAppProvider :one
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
ON CONFLICT (name, user_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
api_url = EXCLUDED.api_url,
webhook_url = EXCLUDED.webhook_url,
access_token = EXCLUDED.access_token,
updated_at = NOW()
RETURNING id, created_at, updated_at;
-- name: InsertGitProvider :exec
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW());
-- name: GetGitProviderTimestamps :one
SELECT created_at, updated_at FROM git_providers WHERE id = $1;
-- name: GetGitProviderForRepos :one
SELECT name, display_name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2;
-- name: GetGitProviderName :one
SELECT name FROM git_providers WHERE id = $1 AND user_id = $2;
-- name: GetGitProviderForFetch :one
SELECT name, api_url, access_token
FROM git_providers
WHERE id = $1 AND user_id = $2;
-- name: DeleteGitProvider :execrows
DELETE FROM git_providers WHERE id = $1 AND user_id = $2;
-- name: ListGitRepositories :many
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url,
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
ORDER BY updated_at DESC
LIMIT $4 OFFSET $5;
-- name: CountGitRepositories :one
SELECT COUNT(*)
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'));
-- name: GetGitRepositoryByFullName :one
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url,
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3;
-- name: InsertGitRepository :one
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
RETURNING created_at, updated_at;
-- name: UpsertGitRepository :exec
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
ON CONFLICT (provider_id, full_name)
DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
clone_url = EXCLUDED.clone_url,
default_branch = EXCLUDED.default_branch,
is_private = EXCLUDED.is_private,
updated_at = NOW();
-- name: GetGitRepositoryProviderID :one
SELECT provider_id FROM git_repositories WHERE id = $1 AND user_id = $2;
-- name: UpsertGitWebhook :one
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
ON CONFLICT (repo_id, provider_id)
DO UPDATE SET
events = EXCLUDED.events,
webhook_secret = EXCLUDED.webhook_secret,
active = TRUE,
branch_filter = EXCLUDED.branch_filter,
updated_at = NOW()
RETURNING id, active, created_at, updated_at;
-- name: ListConnectedRepositories :many
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, '') AS description, r.clone_url,
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
p.name AS provider_name, p.display_name
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3;
-- name: CountUserRepositories :one
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1;
-- name: GetGitWebhookForPush :one
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, '') AS branch_filter, active
FROM git_webhooks WHERE id = $1;
-- name: GetGitProviderNameByID :one
SELECT name FROM git_providers WHERE id = $1;
-- name: GetGitRepoForPush :one
SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1;
-- name: ListServicesForPush :many
SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
s.created_at, s.updated_at
FROM services s
WHERE (s.git_branch = $1 OR $1 = '') AND (s.git_repo = $2 OR s.git_repo = $3);
-- name: GetGitRepoCloneByFullName :one
SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1;
-- name: GetGitRepoProviderByCloneURL :one
SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1;
+147
View File
@@ -0,0 +1,147 @@
-- name: UpsertNodeMetrics :exec
INSERT INTO node_metrics (
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28
)
ON CONFLICT (node_id, timestamp) DO UPDATE SET
cpu_usage = EXCLUDED.cpu_usage,
cpu_cores = EXCLUDED.cpu_cores,
load_avg_1 = EXCLUDED.load_avg_1,
load_avg_5 = EXCLUDED.load_avg_5,
load_avg_15 = EXCLUDED.load_avg_15,
memory_total = EXCLUDED.memory_total,
memory_used = EXCLUDED.memory_used,
memory_available = EXCLUDED.memory_available,
memory_usage_percent = EXCLUDED.memory_usage_percent,
storage_total = EXCLUDED.storage_total,
storage_used = EXCLUDED.storage_used,
storage_available = EXCLUDED.storage_available,
storage_usage_percent = EXCLUDED.storage_usage_percent,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture;
-- name: UpsertServiceMetrics :exec
INSERT INTO service_metrics (
service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23
)
ON CONFLICT (service_id, timestamp) DO UPDATE SET
requests_total = EXCLUDED.requests_total,
requests_success = EXCLUDED.requests_success,
requests_errors = EXCLUDED.requests_errors,
requests_avg_latency = EXCLUDED.requests_avg_latency,
requests_p95_latency = EXCLUDED.requests_p95_latency,
requests_p99_latency = EXCLUDED.requests_p99_latency,
requests_throughput = EXCLUDED.requests_throughput,
errors_total = EXCLUDED.errors_total,
errors_rate = EXCLUDED.errors_rate,
performance_response_time = EXCLUDED.performance_response_time,
performance_throughput = EXCLUDED.performance_throughput,
performance_concurrency = EXCLUDED.performance_concurrency,
performance_saturation = EXCLUDED.performance_saturation,
performance_utilization = EXCLUDED.performance_utilization,
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score;
-- name: UpsertInstanceMetrics :exec
INSERT INTO instance_metrics (
service_id, timestamp, instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21
)
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET
node_id = EXCLUDED.node_id,
status = EXCLUDED.status,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
start_time = EXCLUDED.start_time,
last_seen = EXCLUDED.last_seen,
health_status = EXCLUDED.health_status,
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count;
-- name: ListNodeMetrics :many
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
coalesce(extract(epoch from uptime), 0)::bigint AS uptime_seconds,
processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC;
-- name: ListServiceMetrics :many
SELECT service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC;
-- name: ListInstanceMetrics :many
SELECT instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2;
-- name: AggregateNodeMetrics :many
-- Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
-- bucket-sized buckets, then back to timestamptz.
SELECT date_bin($1::interval, timestamp, '2000-01-01'::timestamptz) AS bucket,
AVG(cpu_usage)::float8 AS avg_cpu,
AVG(memory_usage_percent)::float8 AS avg_memory,
AVG(storage_usage_percent)::float8 AS avg_storage
FROM node_metrics
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4
GROUP BY bucket
ORDER BY bucket ASC;
+29
View File
@@ -0,0 +1,29 @@
-- name: CountProjects :one
SELECT COUNT(*) FROM projects;
-- name: GetRegistryAuth :one
SELECT username, password FROM registries
WHERE owner_id::text = $1 AND host = $2;
-- name: UpdateServiceRuntimeMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3;
-- name: UpdateServiceDeployMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2,
volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4::text, ''), git_branch = NULLIF($5::text, ''), builder = $6
WHERE id = $7;
-- name: InsertEnvVar :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7);
-- name: DeleteProjectVariables :exec
DELETE FROM project_variables WHERE project_id = $1;
-- name: InsertProjectVariable :exec
INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7);
-- name: GetProjectVariableValue :one
SELECT value FROM project_variables WHERE project_id = $1 AND key = $2;
@@ -17,3 +17,23 @@ UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at
-- name: MarkAllNotificationsReadByUser :exec
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL;
-- name: ListNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1 AND enabled
ORDER BY created_at;
-- name: ListAllNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1
ORDER BY created_at;
-- name: InsertNotificationChannel :one
INSERT INTO notification_channels (user_id, kind, endpoint, token)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at;
-- name: DeleteNotificationChannel :execrows
DELETE FROM notification_channels WHERE id = $1 AND user_id = $2;
+43
View File
@@ -0,0 +1,43 @@
-- name: ListActiveOperationDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY d.created_at ASC;
-- name: ListRecentFailedDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('failed','cancelled','rolled_back')
AND d.updated_at > NOW() - INTERVAL '24 hours'
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY d.updated_at DESC
LIMIT 20;
-- name: ListRecentCronRuns :many
SELECT e.id, j.name AS job_name, j.schedule, e.status, e.started_at, e.finished_at, e.error
FROM cron_executions e
JOIN cron_jobs j ON j.id = e.cron_job_id
JOIN projects p ON p.id = j.project_id
WHERE e.started_at > NOW() - INTERVAL '24 hours'
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY e.started_at DESC
LIMIT 30;
-- name: ListRecentOperationBackups :many
SELECT b.id, b.database_id, ds.name AS db_name, b.status, b.size, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services ds ON ds.id = b.database_id
WHERE b.created_at > NOW() - INTERVAL '24 hours'
AND (ds.user_id = $1 OR $2::bool)
ORDER BY b.created_at DESC
LIMIT 20;
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more