Compare commits

..
18 Commits
Author SHA1 Message Date
Tomáš Dvořák 8f5789bf93 Merge pull request #7 from Dvorinka/feat/dragonfly-selfhosted-auth
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
Dragonfly cache, remote-install fix, self-hosted email-only auth
2026-09-21 21:17:43 +02:00
Tomas DvorakandDevin 3bf44dbcd5 Add global recent-deployments feed for the projects dashboard
The RECENT DEPLOYMENTS section queried the builds table, so
image-sourced services — which deploy without building — never
appeared and the dashboard reported "no deployments yet" while
containers were running.

- GET /api/v1/deployments lists latest deployments across the
  caller's services, joined with service + project names, ordered
  newest-first with a bounded limit
- ProjectsPage consumes it via listRecentDeployments; the DEPLOYS
  stat card now counts real deployments
- is_admin is now projected in register/upsert user responses, and
  the Better Auth mirror grants admin to the first mirrored account
  (previously only the legacy register path did)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 21:07:01 +02:00
Tomas DvorakandDevin b7e2e93261 fix: polish service runtime UX and harden reconcile edge cases
Backend:
- tolerate concurrent project-network creation (re-resolve on conflict)
- always publish an ephemeral host port when a port is configured, even
  with a domain set — Traefik routes on top when the edge network exists
- public_url now prefers the configured domain, falls back to the first
  published port
- service logs resolve replicas by containr.service label and merge all
  replica streams — previously queried a nonexistent containr-<id> name

Canvas/detail:
- degraded status renders amber end-to-end (node card, detail badge,
  status dot) instead of falling through to "Stopped"
- edges pick handle sides by relative node position — no more backward
  loops when the source sits right of the target
- context menu gains Start for stopped services, Visit uses the
  published URL when no domain is set, menu clamps to canvas bounds
- node cards show the image tag instead of a redundant hint
- scaling tab falls back to live runtime replica counts when no
  autoscaler state exists; manual scale no longer requires it

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 20:29:31 +02:00
Tomas Dvorak 04c3b99531 fix: render service icons via function call, not dynamic component 2026-09-21 19:38:41 +02:00
Tomas Dvorak dbdf6733d1 fix: extract service icon into component — react-hooks lint 2026-09-21 19:35:48 +02:00
Tomas DvorakandDevin ff356230fe feat: real service runtime — private networks, replicas, lifecycle, rollback
Services now reconcile into actual Docker containers instead of
bookkeeping rows:

- Per-project bridge network with service-name DNS aliases and
  ${{service.KEY}} env expansion (HOST/PORT builtins)
- Replicas, published ports, Traefik domain routing, health checks,
  restart policies persisted on the service spec; spec-hash drift
  recreates containers on redeploy
- Lifecycle API: start/stop/restart/redeploy, live runtime state,
  real image rollback (replaces the fake sleep)
- Manual scale and PUT replicas reconcile live containers; service
  and project delete remove their containers and the project network
- Canvas: real URLs, replica badges, env filter, right-click
  deploy/restart/stop/delete menu, empty state
- Service detail: real lifecycle buttons, live replica/URL strip,
  Networking editor, ${{ }} variable helper
- Fix prebuilt-image pulls racing the tag (drain the pull stream)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 19:31:19 +02:00
Tomas DvorakandDevin c16b32f56a feat: owner accounts and in-app platform settings
- First registered account becomes admin (users.is_admin, backfilled
  to earliest account); better-auth mirror path does the same
- Public registration closes after bootstrap; admin can reopen it via
  GET/PUT /api/v1/settings (admin-gated) or SIGNUP_ENABLED env
- In-app settings override env vars; secrets stored in app_settings,
  token values never returned by the API
- Saving a Cloudflare Tunnel token pulls and runs a managed
  cloudflared sidecar on the backend's networks; clearing removes it;
  startup restores it from saved settings
- group_add DOCKER_GID grants the non-root backend user Docker socket
  access; install.sh probes the in-container socket gid
- Settings page gains an owner-only Platform section (registration
  toggle, tunnel token, link to Cloudflare dashboard); sign-up page
  stays reachable while registration is open
- OpenAPI spec + generated types updated; manual user creation now
  requires admin

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 18:05:22 +02:00
Tomas DvorakandDevin c3959d13e2 fix: merge vite dev proxy into existing server block
Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:28:04 +02:00
Tomas DvorakandDevin 060038cb6f feat: swap Redis for Dragonfly, fix remote installs, self-hosted email-only auth
- Replace redis:7-alpine with Dragonfly (Redis-protocol compatible; go-redis
  client unchanged) in root, infra, and backend-dev compose files. Bounded
  defaults --proactor_threads/--maxmemory so it boots on small VPSs.
- Frontend now defaults to same-origin API calls; nginx proxies /api/* to the
  backend (WebSocket-aware, docker DNS resolver for upstream re-resolution).
  Removes the baked localhost:8082 that broke installs reached by IP/domain.
- /auth/bootstrap returns configured OAuth providers; the UI hides
  GitHub/Google buttons unless real credentials exist. Self-hosted stays
  email/password only - OAuth reserved for a future managed offering.
- Sidecar ignores PLACEHOLDER_* OAuth credentials and trusts same-host
  origins dynamically (Origin host == request Host) so remote installs pass
  origin checks without config; cross-origin requests still rejected.
- install.sh writes ENVIRONMENT=production; COOKIE_SECURE=false in
  production is now a startup warning instead of fatal (plain-HTTP installs).
- Bootstrap failure surfaces an error notice instead of silently rendering
  the sign-in page.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:24:43 +02:00
Tomáš Dvořák 9ba2bd06c5 Merge pull request #6 from Dvorinka/docs/readme-tighten
CI / Infra (Compose + Scripts) (push) Failing after 5s
CI / Backend (Go) (push) Successful in 1m6s
CI / Frontend (React) (push) Failing after 5m18s
CI / Compose boot smoke (push) Failing after 5s
CI / Build & publish images (push) Skipped
docs: tighten README
2026-09-21 16:56:53 +02:00
Tomas DvorakandDevin 254d896802 docs: tighten README
- fold "What is" into a single intro paragraph, drop the heading
- compress 15 feature bullets into 8 grouped lines
- drop the port table (already in docs/guides + onboarding)
- collapse quick-start subsections into prose; dev/self-hosted details
  point to docs/guides and infra/
- fold the Configuration section into two lines; footer links inline

170 -> 100 lines, no information lost that docs/ doesn't already carry.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:52:54 +02:00
Tomáš Dvořák 3f6e506445 Merge pull request #5 from Dvorinka/feat/standalone-install
feat: standalone curl-pipe installer, no clone required
2026-09-21 16:51:11 +02:00
Tomas DvorakandDevin a0141ae552 feat: standalone curl-pipe installer, no clone required
- install.sh works outside a checkout: installs into CONTAINR_DIR
  (default ./containr), downloads docker-compose.yml + .env.example from
  the repo, generates secrets, pulls published images
- CONTAINR_REF selects the git ref for downloads; CONTAINR_BUILD=1 or a
  failed pull fetches the source tarball and builds locally
- env-var overrides (HTTP_PORT/API_PORT/...) are persisted into .env;
  health-wait ports resolve env -> .env -> defaults
- drop fixed `name: containr` from root compose so installs in different
  directories get distinct project names
- README: curl one-liner is the primary install path

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:43:42 +02:00
Tomáš Dvořák ec1555cbe4 Merge pull request #4 from Dvorinka/docs/readme-screenshots
docs: add app screenshots, default compose to published :latest images
2026-09-21 16:26:06 +02:00
Tomas DvorakandDevin 430370dbc7 docs: add app screenshots, default compose to published :latest images
- docker-compose.yml now references ghcr.io/dvorinka/containr-*:latest with
  pull_policy: missing, so `docker compose up` pulls published images by
  default; CONTAINR_VERSION pins a release tag, build config retained for
  source builds
- install.sh pulls instead of always building (CONTAINR_BUILD=1 or a failed
  pull falls back to a source build)
- README gains a Screenshots section (dashboard, canvas, service detail,
  databases) captured from a live local stack, plus updated install docs

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:20:16 +02:00
Tomáš Dvořák c0733dc30c Merge pull request #3 from Dvorinka/fix/better-auth-cve
fix: security sweep - better-auth 1.6.33, node 22, go 1.26
2026-09-21 14:42:32 +02:00
Tomas DvorakandDevin 479eeb021b fix(deps): patch fixable Go vulns, build on go 1.26
- golang-jwt/v5 5.0.0->5.2.2 (CVE-2025-30204), x/crypto, x/net,
  x/text, otel -> latest; go.mod now requires go 1.26
- builder golang:1.24-alpine -> 1.26-alpine, clearing the stdlib
  HIGH batch (crypto/tls, net/http, x509, net/mail DoS)
- npm-side findings (tar, brace-expansion, pacote, sigstore, ...)
  are npm-CLI internals inside the node base image, inert at
  runtime - no overrides; fixed by base image updates

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 14:36:28 +02:00
Tomas DvorakandDevin a083c30d76 fix(auth): bump better-auth to 1.6.33, node to 22
- CVE-2026-53514 (HIGH): better-auth <1.6.11 vulnerable to
  unverified-email invitation acceptance; pinned 1.6.33 (1.7.x breaks
  the migration introspection + schema check on this setup)
- qs override to >=6.16.0 clears moderate DoS via express
- kysely audit fix pulled 0.29.x requiring node>=22; node:20 is EOL
  anyway - both Dockerfiles now on node:22-alpine, matching CI
- npm audit: 0 vulnerabilities; e2e verified (migrations run,
  sign-up returns session)

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 14:15:21 +02:00
57 changed files with 4025 additions and 696 deletions

No files matched your search

+32 -6
View File
@@ -1,4 +1,5 @@
# Local development env file. Use .env.prod for production. # Local development env file. install.sh writes ENVIRONMENT=production for
# self-hosted installs - keep development here only for the checkout workflow.
# Domain Configuration # Domain Configuration
DOMAIN=localhost DOMAIN=localhost
@@ -11,6 +12,10 @@ API_PORT=8082
POSTGRES_PORT=5432 POSTGRES_PORT=5432
REDIS_PORT=6379 REDIS_PORT=6379
# Image tag to pull from GHCR (e.g. latest, 0.1.1). Use CONTAINR_BUILD=1
# with install.sh to build from source instead.
CONTAINR_VERSION=latest
# Database Configuration # Database Configuration
POSTGRES_DB=containr POSTGRES_DB=containr
POSTGRES_USER=containr_user POSTGRES_USER=containr_user
@@ -24,9 +29,12 @@ AUTO_MIGRATE=true
MIGRATION_LOCK_TIMEOUT=2m MIGRATION_LOCK_TIMEOUT=2m
SEED_DATA_ON_START=false SEED_DATA_ON_START=false
# Redis Configuration # Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility)
REDIS_PASSWORD=your_secure_redis_password REDIS_PASSWORD=your_secure_redis_password
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0 REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
# DRAGONFLY_VERSION=latest
# DRAGONFLY_THREADS=1
# DRAGONFLY_MAXMEMORY=256mb
# Application Configuration # Application Configuration
# In production this must be a strong value with at least 32 characters. # In production this must be a strong value with at least 32 characters.
@@ -36,8 +44,11 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
# Optional rotation list (comma-separated). When set, this takes precedence. # Optional rotation list (comma-separated). When set, this takes precedence.
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret # CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
CORS_ORIGINS=http://localhost:3000,http://localhost:8080 CORS_ORIGINS=http://localhost:3000,http://localhost:8080
VITE_API_URL=http://localhost:8082 # Optional build-time API URL overrides. Leave empty for the default
VITE_AUTH_URL=http://localhost:8082/api/auth # same-origin setup - the frontend nginx proxies /api to the backend, which
# works from any host or IP without rebuilding the image.
# VITE_API_URL=https://api.example.com
# VITE_AUTH_URL=https://api.example.com/api/auth
AUTH_PORT=3001 AUTH_PORT=3001
# In production this must be true. # In production this must be true.
COOKIE_SECURE=false COOKIE_SECURE=false
@@ -50,6 +61,8 @@ BETTER_AUTH_AUTO_MIGRATE=true
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001 BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
# Extra trusted origins (comma-separated). Same-origin traffic is trusted
# automatically, so this only needs entries for cross-origin callers.
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
# Optional explicit auth DB settings (recommended when password contains URL special chars) # Optional explicit auth DB settings (recommended when password contains URL special chars)
DB_HOST=postgres DB_HOST=postgres
@@ -60,7 +73,9 @@ DB_PASSWORD=your_secure_postgres_password
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:8082 BACKEND_URL=http://localhost:8082
# OAuth (user auth) # OAuth (user auth) - GitHub/Google sign-in is reserved for the managed
# offering. Self-hosted installs use email/password only; PLACEHOLDER_*
# values are treated as unset and the buttons stay hidden.
GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
@@ -105,10 +120,21 @@ TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
# Development convenience: true in local dev, false in production # Development convenience: true in local dev, false in production
TRAEFIK_API_INSECURE=true TRAEFIK_API_INSECURE=true
# Optional: Cloudflare Tunnel (alternative to domain) # Optional: Cloudflare Tunnel (alternative to domain). Can also be set in-app
# under Settings > Platform (in-app value overrides this).
# Get token from: https://dash.cloudflare.com/argotunnel # Get token from: https://dash.cloudflare.com/argotunnel
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
# Optional: reopen public registration after the owner account exists.
# Manageable in-app under Settings > Platform (in-app value overrides this).
# SIGNUP_ENABLED=false
# Docker socket gid AS SEEN INSIDE containers - lets the backend manage
# containers (deployments, cloudflared sidecar). install.sh detects it with a
# throwaway container. Modern Docker maps the socket to root: use 0.
# Older daemons expose the host docker gid: stat -c %g /var/run/docker.sock
DOCKER_GID=0
# Optional: Custom Docker Registry # Optional: Custom Docker Registry
# DOCKER_REGISTRY=your-registry.com # DOCKER_REGISTRY=your-registry.com
# DOCKER_USERNAME=your_username # DOCKER_USERNAME=your_username
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_SECRET=$(openssl rand -hex 32)
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik
- name: Wait for backend health - name: Wait for backend health
run: | run: |
+2
View File
@@ -121,3 +121,5 @@ bin
# Local reference checkouts (not project code) # Local reference checkouts (not project code)
ref/ ref/
.playwright-mcp/
canvas-*.png
+59 -99
View File
@@ -11,8 +11,8 @@
<p align="center"> <p align="center">
<a href="#quick-start">Quick Start</a> • <a href="#quick-start">Quick Start</a> •
<a href="#screenshots">Screenshots</a> •
<a href="#features">Features</a> • <a href="#features">Features</a> •
<a href="#architecture">Architecture</a> •
<a href="docs/guides/">Guides</a> • <a href="docs/guides/">Guides</a> •
<a href="CONTRIBUTING.md">Contributing</a> <a href="CONTRIBUTING.md">Contributing</a>
</p> </p>
@@ -22,124 +22,84 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
</p> </p>
## What is Containr? Containr is an open-source control plane for Docker. Create a project, drop
services onto a visual canvas, wire them together with environment variables,
and deploy — no Kubernetes, no cloud bill. Services appear as nodes with
auto-inferred connections; logs, metrics, builds, and rollbacks are one
click away.
Containr is an open-source control plane for Docker. It gives you a ## Screenshots
project-first deployment workflow — create a project, drop services onto a
visual canvas, wire them together with environment variables, and deploy —
without Kubernetes, without a cloud bill, and without giving up control of
your infrastructure.
The UI is inspired by Railway's project canvas: services appear as nodes, | Projects dashboard | Project canvas |
connections are inferred automatically, and everything — logs, metrics, | --- | --- |
deployments, builds — is one click away. | ![Projects dashboard with host telemetry](docs/screenshots/dashboard.png) | ![Visual service canvas with two services](docs/screenshots/canvas.png) |
| Service detail | Managed databases |
| --- | --- |
| ![Service view with metrics, logs and config tabs](docs/screenshots/service-detail.png) | ![Managed database list showing a running PostgreSQL](docs/screenshots/databases.png) |
## Features ## Features
- **Project canvas** — visual service topology with groups, drag/drop layout, - **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions
and auto-inferred connections - **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
- **Deployments** — Docker-based deploys with history, logs, and rollback - **Deployments & builds** — Docker deploys with history, logs, image rollback, live build status
- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea; - **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
repository and branch pickers, webhooks, GitHub App support - **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
- **Builds** — build pipeline with live status over WebSocket - **Managed databases** — one-click provisioning with backup/restore
- **Service metrics** — real CPU/memory/network telemetry from Docker stats - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **Templates** — one-click service catalog (databases, apps, tooling) - **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys
- **Databases** — managed database services with backup/restore actions - **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vuln scans
- **Cron jobs** — scheduled jobs with execution history - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
- **Preview environments** — per-project preview deploys
- **Variables & environments** — per-service env vars, production/preview/dev ## Quick Start
- **Node monitoring** — host CPU, memory, disk, and network telemetry
- **Security** — audit logs, vulnerability scans, compliance reports Requires Docker with the Compose plugin, `openssl`, and `curl`:
- **Auth** — Better Auth sessions (email + OAuth), per-user resource scoping
- **Self-hosted networking** — Traefik reverse proxy, optional Cloudflare ```bash
Tunnel for exposure without port forwarding curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | bash
```
Installs into `./containr`, generates secrets, pulls the published `:latest`
images, and starts the stack — UI at `http://localhost:3000`, API at
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
On first visit you create the owner account (email/password); registration
then closes automatically. The owner can reopen it and configure a Cloudflare
Tunnel token under **Settings → Platform** — in-app values override env vars.
Override with env vars:
```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | \
HTTP_PORT=8080 CONTAINR_DIR=/opt/containr bash
```
`CONTAINR_VERSION` pins a release tag, `CONTAINR_REF` selects the git ref for
downloaded files, `CONTAINR_BUILD=1` builds from source. From a git checkout,
`./install.sh` installs in place; plain `docker compose up -d` pulls the same
images (`--build` to compile locally).
Local development and self-hosted deployment (Traefik + Cloudflare Tunnel):
see [docs/guides/](docs/guides/) and `infra/docker-compose.yml`.
## Architecture ## Architecture
``` ```
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar app/backend/ Go API (Gin) · PostgreSQL · Dragonfly · Docker SDK · Better Auth sidecar
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
docs/ Guides, OpenAPI spec, design documents docs/ Guides, OpenAPI spec, design documents
``` ```
| Component | Default port | All configuration is via environment variables — see `.env.example`. Never
| ------------ | ------------ | commit `.env` or `.env.prod`; if a secret was ever committed, rotate it.
| Frontend | 3000 (prod) / 5173 (dev) |
| API | 8082 |
| Auth sidecar | 3001 |
| PostgreSQL | 5432 |
| Redis | 6379 |
## Quick Start
### Easy install (recommended)
Requires Docker with the Compose plugin, `openssl`, and `curl`.
```bash
git clone https://github.com/Dvorinka/Containr.git
cd Containr
./install.sh
```
The script creates `.env` from `.env.example`, fills in all required secrets
with random values, builds the images, and starts the stack. It keeps an
existing `.env` untouched, so it is safe to re-run.
Frontend at `http://localhost:3000`, API at `http://localhost:8082`.
### Docker Compose (manual)
```bash
cp .env.example .env # fill in secrets
docker compose up -d --build
```
Host ports can be remapped in `.env` (`HTTP_PORT`, `API_PORT`,
`POSTGRES_PORT`, `REDIS_PORT`) if the defaults collide with other services.
### Local development
```bash
docker compose up -d postgres redis # data services only
cd app/backend && go run ./cmd/server # API + embedded auth sidecar
cd app/frontend && npm install && npm run dev
```
Frontend dev server at `http://localhost:5173`.
### Self-hosted with Traefik + Cloudflare Tunnel
See `infra/docker-compose.yml` and
[docs/guides/CLOUDFLARE_SETUP.md](docs/guides/CLOUDFLARE_SETUP.md).
## Configuration
All configuration is via environment variables — see `.env.example`.
Required for production: `JWT_SECRET`, `BETTER_AUTH_SECRET`,
`BETTER_AUTH_INTERNAL_TOKEN`, database and Redis credentials.
**Never commit `.env` or `.env.prod`.** Both are gitignored; if a secret was
ever committed, rotate it.
## Documentation ## Documentation
- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling - [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract - [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding - [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
- [docs/design/](docs/design/) — UI design references
- `make help` — common tasks - `make help` — common tasks
## Contributing ---
See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome. [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [MIT License](LICENSE) © 2026 Tomas Dvorak
## Security
See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not
open public issues.
## License
[MIT](LICENSE) © 2026 Tomas Dvorak
+3 -3
View File
@@ -1,5 +1,5 @@
# Build Go API binary # Build Go API binary
FROM golang:1.24-alpine AS go-builder FROM golang:1.26-alpine AS go-builder
RUN apk add --no-cache git ca-certificates tzdata RUN apk add --no-cache git ca-certificates tzdata
@@ -12,7 +12,7 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
# Build embedded Better Auth runtime dependencies # Build embedded Better Auth runtime dependencies
FROM node:20-alpine AS auth-builder FROM node:22-alpine AS auth-builder
WORKDIR /auth WORKDIR /auth
@@ -22,7 +22,7 @@ RUN npm ci --omit=dev
COPY auth/src ./src COPY auth/src ./src
# Final runtime image (Go API + Better Auth sidecar in one container) # Final runtime image (Go API + Better Auth sidecar in one container)
FROM node:20-alpine FROM node:22-alpine
RUN apk --no-cache add ca-certificates tzdata wget RUN apk --no-cache add ca-certificates tzdata wget
+1 -1
View File
@@ -5,7 +5,7 @@
### Prerequisites ### Prerequisites
- Go 1.21+ - Go 1.21+
- PostgreSQL 12+ - PostgreSQL 12+
- Redis (optional) - Dragonfly (Redis-compatible cache)
### Environment Variables ### Environment Variables
+153 -127
View File
@@ -8,7 +8,7 @@
"name": "containr-auth", "name": "containr-auth",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"better-auth": "^1.3.4", "better-auth": "^1.6.33",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^4.21.2", "express": "^4.21.2",
"kysely": "^0.28.15", "kysely": "^0.28.15",
@@ -16,38 +16,39 @@
} }
}, },
"node_modules/@better-auth/core": { "node_modules/@better-auth/core": {
"version": "1.5.6", "version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.33.tgz",
"integrity": "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==", "integrity": "sha512-DTadFTdcEad+IAmcJAEVGjD+6wtjt8BkLqmfBdRK+AeYOHaKy/gwtSlmxDuHKfsRaOmn1P8/gtEi/L5wVeEojQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@opentelemetry/semantic-conventions": "^1.39.0", "@opentelemetry/semantic-conventions": "^1.39.0",
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"peerDependencies": { "peerDependencies": {
"@better-auth/utils": "0.3.1", "@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.1.21", "@better-fetch/fetch": "1.3.1",
"@cloudflare/workers-types": ">=4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"better-call": "1.3.2", "better-call": "1.4.0",
"jose": "^6.1.0", "jose": "^6.1.0",
"kysely": "^0.28.5", "kysely": "^0.28.5 || ^0.29.0",
"nanostores": "^1.0.1" "nanostores": "^1.0.1"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@cloudflare/workers-types": { "@opentelemetry/api": {
"optional": true "optional": true
} }
} }
}, },
"node_modules/@better-auth/drizzle-adapter": { "node_modules/@better-auth/drizzle-adapter": {
"version": "1.5.6", "version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.33.tgz",
"integrity": "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==", "integrity": "sha512-/BXRx0FoqoWKV3LZLWjnPJHHGvXEZro7goVzzV7K7ZPZG+PbBa+HYN+eJuXSMFb3JCwJEhW+JNLHWekzHQnTLA==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@better-auth/core": "1.5.6", "@better-auth/core": "^1.6.33",
"@better-auth/utils": "^0.3.0", "@better-auth/utils": "0.4.2",
"drizzle-orm": ">=0.41.0" "drizzle-orm": "^0.45.2"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"drizzle-orm": { "drizzle-orm": {
@@ -56,13 +57,14 @@
} }
}, },
"node_modules/@better-auth/kysely-adapter": { "node_modules/@better-auth/kysely-adapter": {
"version": "1.5.6", "version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.33.tgz",
"integrity": "sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==", "integrity": "sha512-CUYXGxJhx29iilmCr2jdzWBGg/Iq1XjghGlRO15N5SsR+hz8KOGob57UFMVtHEQAhN3tcdgJNllx/ZLmNCyO/A==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@better-auth/core": "1.5.6", "@better-auth/core": "^1.6.33",
"@better-auth/utils": "^0.3.0", "@better-auth/utils": "0.4.2",
"kysely": "^0.27.0 || ^0.28.0" "kysely": "^0.28.17 || ^0.29.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"kysely": { "kysely": {
@@ -71,21 +73,23 @@
} }
}, },
"node_modules/@better-auth/memory-adapter": { "node_modules/@better-auth/memory-adapter": {
"version": "1.5.6", "version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.33.tgz",
"integrity": "sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==", "integrity": "sha512-vw8uuW+zWUd4TLNnmtlwIiUb4jcj/mAopKWohv7ojDR6Esha7ZkM6EsW8W1PVdRhAfLiHeGqh0RvNtFm5mNDjQ==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@better-auth/core": "1.5.6", "@better-auth/core": "^1.6.33",
"@better-auth/utils": "^0.3.0" "@better-auth/utils": "0.4.2"
} }
}, },
"node_modules/@better-auth/mongo-adapter": { "node_modules/@better-auth/mongo-adapter": {
"version": "1.5.6", "version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.33.tgz",
"integrity": "sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==", "integrity": "sha512-JxAENypnmQbYcNboePWafLaP2egLgKQYoKPnZ8dNvaV50/h/dSlBDn1KaUrEMkJWjRXQ2rwgJzUl2yvGxQUZow==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@better-auth/core": "1.5.6", "@better-auth/core": "^1.6.33",
"@better-auth/utils": "^0.3.0", "@better-auth/utils": "0.4.2",
"mongodb": "^6.0.0 || ^7.0.0" "mongodb": "^6.0.0 || ^7.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -95,12 +99,13 @@
} }
}, },
"node_modules/@better-auth/prisma-adapter": { "node_modules/@better-auth/prisma-adapter": {
"version": "1.5.6", "version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.33.tgz",
"integrity": "sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==", "integrity": "sha512-wloYc35VpI7tjuWgOckB4GVX8y0Ow12ER5CfSqc55K88BefgRtR3brruZ9GhHSll+ZIxjf/0p5IGa+z8rKUR2Q==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@better-auth/core": "1.5.6", "@better-auth/core": "^1.6.33",
"@better-auth/utils": "^0.3.0", "@better-auth/utils": "0.4.2",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0"
}, },
@@ -114,31 +119,36 @@
} }
}, },
"node_modules/@better-auth/telemetry": { "node_modules/@better-auth/telemetry": {
"version": "1.5.6", "version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.33.tgz",
"integrity": "sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==", "integrity": "sha512-k8BmE90sWIXlst84zyvCjWoXuftdujCmevlDDI86zXg81Pl+F2AZFPZFXzWkqaNmecnUjbm9bohNGCY6JonWQQ==",
"dependencies": { "license": "MIT",
"@better-auth/utils": "0.3.1",
"@better-fetch/fetch": "1.1.21"
},
"peerDependencies": { "peerDependencies": {
"@better-auth/core": "1.5.6" "@better-auth/core": "^1.6.33",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.1"
} }
}, },
"node_modules/@better-auth/utils": { "node_modules/@better-auth/utils": {
"version": "0.3.1", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz",
"integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==" "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1"
}
}, },
"node_modules/@better-fetch/fetch": { "node_modules/@better-fetch/fetch": {
"version": "1.1.21", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz",
"integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==",
"license": "MIT"
}, },
"node_modules/@noble/ciphers": { "node_modules/@noble/ciphers": {
"version": "2.1.1", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 20.19.0"
}, },
@@ -147,9 +157,10 @@
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "2.0.1", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 20.19.0"
}, },
@@ -157,19 +168,11 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": { "node_modules/@opentelemetry/semantic-conventions": {
"version": "1.40.0", "version": "1.43.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
"license": "Apache-2.0",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
} }
@@ -177,7 +180,8 @@
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
}, },
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
@@ -197,25 +201,26 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
}, },
"node_modules/better-auth": { "node_modules/better-auth": {
"version": "1.5.6", "version": "1.6.33",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.6.tgz", "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.33.tgz",
"integrity": "sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==", "integrity": "sha512-QkVKOmJw/LQ3HjIib2wx2Td0xB9EuPgw0ARYPKeFu3niN0VSBW32j4QwfewFZOQi5Hb/jOB6U+wdvtBhF7+URw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@better-auth/core": "1.5.6", "@better-auth/core": "1.6.33",
"@better-auth/drizzle-adapter": "1.5.6", "@better-auth/drizzle-adapter": "1.6.33",
"@better-auth/kysely-adapter": "1.5.6", "@better-auth/kysely-adapter": "1.6.33",
"@better-auth/memory-adapter": "1.5.6", "@better-auth/memory-adapter": "1.6.33",
"@better-auth/mongo-adapter": "1.5.6", "@better-auth/mongo-adapter": "1.6.33",
"@better-auth/prisma-adapter": "1.5.6", "@better-auth/prisma-adapter": "1.6.33",
"@better-auth/telemetry": "1.5.6", "@better-auth/telemetry": "1.6.33",
"@better-auth/utils": "0.3.1", "@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.1.21", "@better-fetch/fetch": "1.3.1",
"@noble/ciphers": "^2.1.1", "@noble/ciphers": "^2.1.1",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"better-call": "1.3.2", "better-call": "1.4.0",
"defu": "^6.1.4", "defu": "^6.1.4",
"jose": "^6.1.3", "jose": "^6.1.3",
"kysely": "^0.28.12", "kysely": "^0.28.17 || ^0.29.0",
"nanostores": "^1.1.1", "nanostores": "^1.1.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -227,7 +232,7 @@
"@tanstack/solid-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0",
"better-sqlite3": "^12.0.0", "better-sqlite3": "^12.0.0",
"drizzle-kit": ">=0.31.4", "drizzle-kit": ">=0.31.4",
"drizzle-orm": ">=0.41.0", "drizzle-orm": "^0.45.2",
"mongodb": "^6.0.0 || ^7.0.0", "mongodb": "^6.0.0 || ^7.0.0",
"mysql2": "^3.0.0", "mysql2": "^3.0.0",
"next": "^14.0.0 || ^15.0.0 || ^16.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
@@ -301,14 +306,15 @@
} }
}, },
"node_modules/better-call": { "node_modules/better-call": {
"version": "1.3.2", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.2.tgz", "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz",
"integrity": "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==", "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@better-auth/utils": "^0.3.1", "@better-auth/utils": "^0.5.0",
"@better-fetch/fetch": "^1.1.21", "@better-fetch/fetch": "^1.3.1",
"rou3": "^0.7.12", "rou3": "^0.9.1",
"set-cookie-parser": "^3.0.1" "set-cookie-parser": "^3.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^4.0.0" "zod": "^4.0.0"
@@ -319,10 +325,20 @@
} }
} }
}, },
"node_modules/better-call/node_modules/@better-auth/utils": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz",
"integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
"license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
"content-type": "~1.0.5", "content-type": "~1.0.5",
@@ -332,7 +348,7 @@
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
"iconv-lite": "~0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "~2.4.1", "on-finished": "~2.4.1",
"qs": "~6.14.0", "qs": "~6.16.0",
"raw-body": "~2.5.3", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
@@ -714,17 +730,19 @@
} }
}, },
"node_modules/jose": { "node_modules/jose": {
"version": "6.2.2", "version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/kysely": { "node_modules/kysely": {
"version": "0.28.15", "version": "0.28.17",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.15.tgz", "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz",
"integrity": "sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==", "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==",
"license": "MIT",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
} }
@@ -797,15 +815,16 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}, },
"node_modules/nanostores": { "node_modules/nanostores": {
"version": "1.2.0", "version": "1.5.3",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.3.tgz",
"integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==", "integrity": "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"engines": { "engines": {
"node": "^20.0.0 || >=22.0.0" "node": "^20.0.0 || >=22.0.0"
} }
@@ -982,11 +1001,13 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@@ -1018,9 +1039,10 @@
} }
}, },
"node_modules/rou3": { "node_modules/rou3": {
"version": "0.7.12", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz",
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==" "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==",
"license": "MIT"
}, },
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
@@ -1089,9 +1111,10 @@
} }
}, },
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "3.1.0", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==" "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
"license": "MIT"
}, },
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
@@ -1099,13 +1122,14 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
@@ -1117,12 +1141,13 @@
} }
}, },
"node_modules/side-channel-list": { "node_modules/side-channel-list": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3" "object-inspect": "^1.13.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -1235,9 +1260,10 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+4 -1
View File
@@ -8,10 +8,13 @@
"start": "node src/server.js" "start": "node src/server.js"
}, },
"dependencies": { "dependencies": {
"better-auth": "^1.3.4", "better-auth": "1.6.33",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^4.21.2", "express": "^4.21.2",
"kysely": "^0.28.15", "kysely": "^0.28.15",
"pg": "^8.16.3" "pg": "^8.16.3"
},
"overrides": {
"qs": "^6.16.0"
} }
} }
+32 -6
View File
@@ -46,10 +46,15 @@ const database = new PostgresDialect({
pool, pool,
}); });
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || ''; const realCredential = (value) => {
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || ''; const trimmed = (value || '').trim();
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || ''; return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : '';
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || ''; };
const githubClientId = realCredential(env.GITHUB_CLIENT_ID);
const githubClientSecret = realCredential(env.GITHUB_CLIENT_SECRET);
const googleClientId = realCredential(env.GOOGLE_CLIENT_ID);
const googleClientSecret = realCredential(env.GOOGLE_CLIENT_SECRET);
const socialProviders = {}; const socialProviders = {};
if (githubClientId && githubClientSecret) { if (githubClientId && githubClientSecret) {
@@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) {
}; };
} }
const trustedOrigins = [ const staticTrustedOrigins = [
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS), ...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
env.FRONTEND_URL, env.FRONTEND_URL,
env.BACKEND_URL, env.BACKEND_URL,
@@ -86,12 +91,33 @@ const trustedOrigins = [
.map((origin) => (origin || '').trim()) .map((origin) => (origin || '').trim())
.filter(Boolean); .filter(Boolean);
// Self-hosted installs are reached over arbitrary hosts/IPs, so the static
// list above cannot cover them. Trust an Origin only when it matches the
// request's own Host header - that is genuine same-origin traffic (browsers
// cannot forge either header cross-origin). Do NOT consult x-forwarded-host
// here: clients can set it freely.
const resolveTrustedOrigins = (request) => {
const origins = new Set(staticTrustedOrigins);
const host = request?.headers?.get('host');
const origin = request?.headers?.get('origin');
if (host && origin) {
try {
if (new URL(origin).host === host) {
origins.add(origin);
}
} catch {
// malformed Origin header - leave untrusted
}
}
return [...origins];
};
export const auth = betterAuth({ export const auth = betterAuth({
appName: env.BETTER_AUTH_APP_NAME || 'Containr', appName: env.BETTER_AUTH_APP_NAME || 'Containr',
baseURL: env.BETTER_AUTH_URL || defaultBackendURL, baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
basePath: '/api/auth', basePath: '/api/auth',
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN', secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
trustedOrigins, trustedOrigins: resolveTrustedOrigins,
database, database,
user: { user: {
modelName: 'auth_users', modelName: 'auth_users',
+3
View File
@@ -89,6 +89,9 @@ func main() {
// Setup Gin router // Setup Gin router
if cfg.IsProduction() { if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
if !cfg.CookieSecure {
log.Println("Warning: COOKIE_SECURE=false in production - session cookies are sent over plain HTTP. Enable TLS and set COOKIE_SECURE=true.")
}
} }
router := gin.New() router := gin.New()
+10 -6
View File
@@ -14,15 +14,15 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
redis: dragonfly:
image: redis:7-alpine image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
env_file: env_file:
- ../../.env.prod - ../../.env.prod
command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""] command: ["sh", "-c", "exec dragonfly --requirepass \"$${REDIS_PASSWORD}\" --proactor_threads=1 --maxmemory=256mb --dir=/data"]
ports: ports:
- "6379:6379" - "6379:6379"
volumes: volumes:
- backend_redis_data:/data - backend_dragonfly_data:/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
interval: 10s interval: 10s
@@ -38,10 +38,14 @@ services:
- ../../.env.prod - ../../.env.prod
ports: ports:
- "8080:8080" - "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-0}"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: dragonfly:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
@@ -52,4 +56,4 @@ services:
volumes: volumes:
backend_postgres_data: backend_postgres_data:
backend_redis_data: backend_dragonfly_data:
+13 -13
View File
@@ -1,6 +1,6 @@
module containr module containr
go 1.24.0 go 1.26.0
require ( require (
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
@@ -8,18 +8,19 @@ require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.1
github.com/go-redis/redis/v8 v8.11.5 github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.0.0 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/pressly/goose/v3 v3.24.1 github.com/pressly/goose/v3 v3.24.1
github.com/robfig/cron/v3 v3.0.1
github.com/rs/cors v1.10.1 github.com/rs/cors v1.10.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/sqlc-dev/pqtype v0.3.0 github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.57.0
) )
require ( require (
@@ -61,7 +62,6 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
@@ -73,22 +73,22 @@ require (
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
+22 -22
View File
@@ -65,8 +65,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -182,20 +182,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -205,26 +205,26 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
+58 -27
View File
@@ -46,6 +46,7 @@ type User struct {
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"` AvatarURL string `json:"avatar_url,omitempty"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
@@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) {
var user User var user User
var hashedPassword string var hashedPassword string
err := db.QueryRow(` err := db.QueryRow(`
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
FROM users FROM users
WHERE email = $1 WHERE email = $1
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt) `, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password) authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
@@ -120,13 +121,32 @@ func handleAuthBootstrap(c *gin.Context) {
mode = "register" mode = "register"
} }
providers := []string{}
for _, provider := range []string{"GITHUB", "GOOGLE"} {
if oauthProviderConfigured(provider) {
providers = append(providers, strings.ToLower(provider))
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"has_users": count > 0, "has_users": count > 0,
"user_count": count, "user_count": count,
"mode": mode, "mode": mode,
"providers": providers,
"signup_enabled": signupEnabled(db),
}) })
} }
// oauthProviderConfigured reports whether an OAuth provider has real
// credentials. Placeholder values from .env.example do not count, so
// self-hosted installs default to email/password only.
func oauthProviderConfigured(provider string) bool {
id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID"))
secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET"))
return id != "" && secret != "" &&
!strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_")
}
type betterAuthLoginUser struct { type betterAuthLoginUser struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
@@ -236,17 +256,23 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser
return User{}, err return User{}, err
} }
// 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 {
return User{}, err
}
var user User var user User
err = db.QueryRow(` err = db.QueryRow(`
INSERT INTO users (email, password_hash, name, avatar_url) INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4, '')) VALUES ($1, $2, $3, NULLIF($4, ''), $5)
ON CONFLICT (email) DO UPDATE ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name, SET name = EXCLUDED.name,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
updated_at = NOW() updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)). `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil { if err != nil {
return User{}, err return User{}, err
} }
@@ -264,24 +290,26 @@ func handleRegister(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
jwtSecret := c.MustGet("jwt_secret").(string) jwtSecret := c.MustGet("jwt_secret").(string)
count, err := countLocalUsers(db) total, err := countLocalUsers(db)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
if count > 0 { // Closed once the first account exists unless the owner reopens registration.
if total > 0 && !signupEnabled(db) {
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"}) c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
return return
} }
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count) var existing int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
if count > 0 { if existing > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return return
} }
@@ -293,13 +321,13 @@ func handleRegister(c *gin.Context) {
return return
} }
// Create user // First account owns the platform.
var user User var user User
err = db.QueryRow(` err = db.QueryRow(`
INSERT INTO users (email, password_hash, name) INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at 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.CreatedAt) `, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
@@ -320,13 +348,16 @@ func handleRegister(c *gin.Context) {
} }
func handleCreateUser(c *gin.Context) { func handleCreateUser(c *gin.Context) {
db, ok := requireAdmin(c)
if !ok {
return
}
var req ManualUserCreateRequest var req ManualUserCreateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
db := c.MustGet("db").(*database.DB)
req.Email = strings.ToLower(strings.TrimSpace(req.Email)) req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
if req.Email == "" || req.Name == "" { if req.Email == "" || req.Name == "" {
@@ -363,8 +394,8 @@ func handleCreateUser(c *gin.Context) {
err = db.QueryRow(` err = db.QueryRow(`
INSERT INTO users (email, password_hash, name) INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at 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.CreatedAt) `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil { if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return return
@@ -459,10 +490,10 @@ func handleGetProfile(c *gin.Context) {
var user User var user User
err := db.QueryRow(` err := db.QueryRow(`
SELECT id, email, name, COALESCE(avatar_url, ''), created_at SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
FROM users FROM users
WHERE id = $1 WHERE id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+3 -1
View File
@@ -67,7 +67,9 @@ func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *d
return true return true
} }
return count > 0 // Registration is closed once the first account exists unless the owner
// reopens it in Settings (signup_enabled).
return count > 0 && !signupEnabled(db)
} }
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) { func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
+193 -22
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -120,6 +121,72 @@ func handleGetDeployments(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deployments": deployments}) c.JSON(http.StatusOK, gin.H{"deployments": deployments})
} }
// RecentDeployment is a deployment row joined with its service and project names.
type RecentDeployment struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status string `json:"status"`
ImageName string `json:"image_name"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
CreatedAt time.Time `json:"created_at"`
}
// handleGetRecentDeployments lists the latest deployments across all of the
// caller's services — feeds the dashboard deploy feed.
func handleGetRecentDeployments(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
limit := 10
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
limit = n
}
}
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.owner_id = $1
ORDER BY d.created_at DESC
LIMIT $2`,
userID.(string), limit,
)
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)
}
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
}
func handleCreateDeployment(c *gin.Context) { func handleCreateDeployment(c *gin.Context) {
db, exists := c.Get("db") db, exists := c.Get("db")
if !exists { if !exists {
@@ -153,8 +220,11 @@ func handleCreateDeployment(c *gin.Context) {
var service Service var service Service
var projectOwner string var projectOwner string
err = db.(*database.DB).QueryRow( err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, `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.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'),
s.created_at, s.updated_at, p.owner_id s.created_at, s.updated_at, p.owner_id
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -164,7 +234,8 @@ func handleCreateDeployment(c *gin.Context) {
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt, &projectOwner, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner,
) )
if err != nil { if err != nil {
@@ -256,6 +327,22 @@ func runDeploymentAndSync(
service Service, service Service,
req CreateDeploymentRequest, req CreateDeploymentRequest,
userID string, userID string,
) {
runDeploymentAndSyncWithImage(parentCtx, db, engine, dbDeployment, service, req, userID, "")
}
// runDeploymentAndSyncWithImage deploys imageOverride as a prebuilt image
// (rollback / redeploy of an existing tag) when set; otherwise builds from
// source or pulls the service image.
func runDeploymentAndSyncWithImage(
parentCtx context.Context,
db *database.DB,
engine *deployment.DeploymentEngine,
dbDeployment *DeploymentModel,
service Service,
req CreateDeploymentRequest,
userID string,
imageOverride string,
) { ) {
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute) ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
defer cancel() defer cancel()
@@ -265,21 +352,44 @@ func runDeploymentAndSync(
sourcePath = "." sourcePath = "."
} }
// Resolve the runtime env: stored service variables (with ${{svc.KEY}}
// references expanded) plus any per-deployment overrides.
env, envErr := resolveServiceEnv(db, service)
if envErr != nil {
env = map[string]string{}
}
for k, v := range req.EnvVars {
env[k] = v
}
var command []string
if cmd := strings.TrimSpace(service.Command); cmd != "" {
command = strings.Fields(cmd)
}
replicas := service.Replicas
if replicas < 1 {
replicas = 1
}
deployReq := &deployment.DeploymentRequest{ deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(), ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(), ServiceID: service.ID.String(),
Environment: service.Environment, Environment: service.Environment,
Config: deployment.ServiceConfig{ Config: deployment.ServiceConfig{
Name: service.Name, Name: service.Name,
Image: service.Image, Image: service.Image,
Environment: req.EnvVars, Command: command,
Replicas: 1, Environment: env,
}, Replicas: replicas,
BuildConfig: &deployment.BuildConfig{ PublicPort: int32(service.Port),
BuildType: "nixpacks", Domain: service.Domain,
SourcePath: sourcePath, HealthPath: service.HealthCheckPath,
Branch: req.Branch, RestartPolicy: service.RestartPolicy,
Commit: req.CommitHash, Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory),
CPUQuota: parseCPULimit(service.CPU),
},
}, },
Trigger: deployment.TriggerConfig{ Trigger: deployment.TriggerConfig{
Type: req.Trigger, Type: req.Trigger,
@@ -289,6 +399,26 @@ func runDeploymentAndSync(
}, },
} }
if imageOverride != "" {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: imageOverride,
}
} else if service.GitRepo == "" {
// Image-sourced service: pull the image instead of building.
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: service.Image,
}
} else {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "nixpacks",
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
}
}
engineDeployment, err := engine.Deploy(ctx, deployReq) engineDeployment, err := engine.Deploy(ctx, deployReq)
if err != nil { if err != nil {
failedAt := time.Now() failedAt := time.Now()
@@ -550,17 +680,58 @@ func handleRollbackDeployment(c *gin.Context) {
time.Now(), serviceID, time.Now(), serviceID,
) )
go func() { // Real rollback: redeploy the target deployment's image.
time.Sleep(2 * time.Second) engineValue, _ := c.Get("deployment_engine")
db.(*database.DB).Exec( engine, _ := engineValue.(*deployment.DeploymentEngine)
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`, targetImage := targetDeployment.ImageName
time.Now(), rollbackID, if targetDeployment.ImageTag != "" {
targetImage += ":" + targetDeployment.ImageTag
}
if engine == nil || targetImage == "" {
completedAt := time.Now()
reason := "Rollback image unavailable"
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( _, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, `UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID, completedAt, 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'),
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.CreatedAt, &service.UpdatedAt,
)
rollbackReq := CreateDeploymentRequest{
CommitHash: func() string {
if targetDeployment.CommitHash != nil {
return *targetDeployment.CommitHash
}
return ""
}(),
Trigger: "rollback",
}
// Reuse the stored image directly — no rebuild.
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
}
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
"deployment": DeploymentResponse{ "deployment": DeploymentResponse{
+33 -9
View File
@@ -8,9 +8,11 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
"github.com/docker/docker/api/types/filters"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -73,7 +75,21 @@ func handleGetLogs(c *gin.Context) {
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
return return
} }
containerName := fmt.Sprintf("containr-%s", serviceID) // Replicas are named containr-<serviceID>-<n>; resolve them by label and
// merge logs across all of them.
ctx := context.Background()
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("label", "containr.service="+serviceID.String()),
), false)
if err != nil {
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
return
}
if len(containers) == 0 {
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
return
}
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
logOpts := docker.LogOptions{ logOpts := docker.LogOptions{
Stdout: true, Stdout: true,
@@ -83,17 +99,25 @@ func handleGetLogs(c *gin.Context) {
Timestamps: true, Timestamps: true,
} }
ctx := context.Background() var readers []io.ReadCloser
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts) var streams []io.Reader
if err != nil { for _, ctr := range containers {
if isDockerNotFoundError(err) { r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts)
respondEmptyServiceLogs(c, "No container logs are available for this service yet.") if rerr == nil {
return readers = append(readers, r)
streams = append(streams, r)
} }
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") }
if len(readers) == 0 {
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
return return
} }
defer logsReader.Close() logsReader := io.MultiReader(streams...)
defer func() {
for _, r := range readers {
_ = r.Close()
}
}()
if follow { if follow {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
@@ -6,6 +6,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"containr/internal/database"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -86,3 +88,30 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
} }
} }
func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/v1/deployments", handleGetRecentDeployments)
req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// No db in context → dependency failure surfaces before auth.
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code)
}
// With db present but no user context → 401.
router2 := gin.New()
router2.GET("/api/v1/deployments", func(c *gin.Context) {
c.Set("db", &database.DB{})
handleGetRecentDeployments(c)
})
rec2 := httptest.NewRecorder()
router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil))
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code)
}
}
+12
View File
@@ -3,6 +3,8 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"containr/internal/deployment"
"context"
"database/sql" "database/sql"
"errors" "errors"
"net/http" "net/http"
@@ -310,6 +312,16 @@ func handleDeleteProject(c *gin.Context) {
return return
} }
// Remove live containers and the project network before dropping rows.
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
engine := engineValue.(*deployment.DeploymentEngine)
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
_ = engine.RemoveProjectContainers(ctx, projectID.String())
_ = engine.RemoveProjectNetwork(ctx, projectID.String())
cancel()
}
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID) deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
+14
View File
@@ -35,6 +35,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
dockerClient = client dockerClient = client
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient) buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient) deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
// Restore managed sidecars (e.g. cloudflared) from saved settings.
syncManagedContainers(dockerClient, db)
} }
// Initialize build handler // Initialize build handler
@@ -184,6 +186,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
protected.PUT("/user/profile", handleUpdateProfile) protected.PUT("/user/profile", handleUpdateProfile)
protected.POST("/users", handleCreateUser) protected.POST("/users", handleCreateUser)
// Platform settings (admin-gated inside handlers)
protected.GET("/settings", handleGetSettings)
protected.PUT("/settings", handleUpdateSettings)
// Project routes // Project routes
protected.GET("/projects", handleGetProjects) protected.GET("/projects", handleGetProjects)
protected.POST("/projects", handleCreateProject) protected.POST("/projects", handleCreateProject)
@@ -203,9 +209,17 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
protected.DELETE("/services/:id", handleDeleteService) protected.DELETE("/services/:id", handleDeleteService)
protected.GET("/services/:id/metrics", handleGetServiceMetrics) protected.GET("/services/:id/metrics", handleGetServiceMetrics)
// Runtime lifecycle (Railway-style)
protected.GET("/services/:id/runtime", handleGetServiceRuntime)
protected.POST("/services/:id/start", handleServiceStart)
protected.POST("/services/:id/stop", handleServiceStop)
protected.POST("/services/:id/restart", handleServiceRestart)
protected.POST("/services/:id/redeploy", handleServiceRedeploy)
// Deployment routes // Deployment routes
protected.GET("/services/:id/deployments", handleGetDeployments) protected.GET("/services/:id/deployments", handleGetDeployments)
protected.POST("/services/:id/deployments", handleCreateDeployment) protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments", handleGetRecentDeployments)
protected.GET("/deployments/:id", handleGetDeployment) protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment) protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
+440
View File
@@ -0,0 +1,440 @@
package api
import (
"context"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"containr/internal/database"
"containr/internal/deployment"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// serviceRuntimeSpec builds the container spec for a service: stored env vars
// merged with ${{Service.KEY}} references resolved across the project.
func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) {
env, err := resolveServiceEnv(db, service)
if err != nil {
return deployment.RuntimeSpec{}, err
}
spec := deployment.RuntimeSpec{
ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(),
Name: service.Name,
Image: service.Image,
Env: env,
Replicas: service.Replicas,
Port: int32(service.Port),
Domain: service.Domain,
HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy,
}
if cmd := strings.TrimSpace(service.Command); cmd != "" {
spec.Command = strings.Fields(cmd)
}
if spec.Replicas < 1 {
spec.Replicas = 1
}
if spec.RestartPolicy == "" {
spec.RestartPolicy = "unless-stopped"
}
spec.NanoCPUs = parseCPULimit(service.CPU)
spec.MemoryBytes = parseMemoryLimit(service.Memory)
if spec.Port > 0 {
env["PORT"] = strconv.Itoa(service.Port)
}
return spec, nil
}
var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`)
// resolveServiceEnv loads the service's variables (real values, including
// 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,
)
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] = v
}
var refServices []struct{ ID, Name string }
svcRows, err := db.Query(
`SELECT id, name FROM services WHERE project_id = $1`,
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{}
for _, s := range refServices {
byName[strings.ToLower(s.Name)] = s.ID
}
resolve := func(serviceName, key string) (string, bool) {
id, ok := byName[strings.ToLower(serviceName)]
if !ok {
return "", false
}
switch strings.ToUpper(key) {
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
}
return "", false
}
var value string
err := db.QueryRow(
`SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`,
id, key,
).Scan(&value)
if err != nil {
return "", false
}
return value, true
}
for k, v := range env {
env[k] = refVarPattern.ReplaceAllStringFunc(v, func(match string) string {
parts := refVarPattern.FindStringSubmatch(match)
if len(parts) != 3 {
return match
}
if resolved, ok := resolve(parts[1], parts[2]); ok {
return resolved
}
return match
})
}
return env, nil
}
func parseCPULimit(cpu string) int64 {
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
if err != nil || v <= 0 {
return 0
}
return int64(v * 1e9)
}
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
func parseMemoryLimit(mem string) int64 {
s := strings.ToLower(strings.TrimSpace(mem))
for suffix, mult := range memUnits {
if strings.HasSuffix(s, suffix) {
if v, err := strconv.ParseFloat(strings.TrimSuffix(s, suffix), 64); err == nil {
return int64(v * float64(mult))
}
}
}
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
return v
}
return 0
}
func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) {
dbValue, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return nil, nil, false
}
engineValue, exists := c.Get("deployment_engine")
if !exists || engineValue == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Deployment engine unavailable. Docker may not be configured on this server."})
return nil, nil, false
}
return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true
}
func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return Service{}, false
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
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'),
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.CreatedAt, &service.UpdatedAt, &owner,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
if owner != userID.(string) && !isAdminUser(db, userID.(string)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return Service{}, false
}
return service, true
}
// handleServiceRuntime returns live container state for a service.
func handleGetServiceRuntime(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
state, err := engine.RuntimeState(c.Request.Context(), service.ID.String(), service.Replicas)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"runtime": state})
}
func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *database.DB, service Service) bool {
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return false
}
status := "stopped"
if state != nil {
status = state.Status
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
c.JSON(http.StatusOK, gin.H{"runtime": state})
return true
}
// handleServiceStart starts all stopped replicas.
func handleServiceStart(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
if err := engine.StartService(c.Request.Context(), service.ID.String()); err != nil {
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)
c.JSON(http.StatusOK, gin.H{"status": "running"})
}
// handleServiceStop stops all replicas without removing them.
func handleServiceStop(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
if err := engine.StopService(c.Request.Context(), service.ID.String()); err != nil {
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)
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
}
// handleServiceRestart restarts all replicas.
func handleServiceRestart(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
ctx := c.Request.Context()
if err := engine.StopService(ctx, service.ID.String()); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if err := engine.StartService(ctx, service.ID.String()); err != nil {
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)
c.JSON(http.StatusOK, gin.H{"status": "running"})
}
// handleServiceRedeploy re-applies the current spec: pulls the image for
// image-sourced services and reconciles containers (env, ports, replicas).
func handleServiceRedeploy(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
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)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"})
return
}
}
service.Image = image
reconcileNow(c, engine, db, service)
}
// liveServiceStatus reconciles the stored status with real container state.
// No-op when Docker is unavailable — the stored status is returned as-is.
func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
engineValue, exists := c.Get("deployment_engine")
if !exists || engineValue == nil {
return
}
engine := engineValue.(*deployment.DeploymentEngine)
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
if err != nil || state == nil || len(state.Containers) == 0 {
return
}
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)
}
if service.Domain != "" {
service.PublicURL = "https://" + service.Domain
} else if len(state.URLs) > 0 {
service.PublicURL = state.URLs[0]
}
}
// runtimeScaleService adjusts replica count both in bookkeeping and reality.
func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
dbValue, _ := c.Get("db")
engineValue, _ := c.Get("deployment_engine")
db, _ := dbValue.(*database.DB)
engine, _ := engineValue.(*deployment.DeploymentEngine)
if db == nil || engine == nil {
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'),
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.CreatedAt, &service.UpdatedAt, &owner,
)
if err != nil {
return err
}
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 {
return fmt.Errorf("no deployed image to scale")
}
}
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
return err
}
spec.Replicas = replicas
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := engine.ReconcileService(ctx, spec); err != nil {
return err
}
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
return nil
}
+20 -3
View File
@@ -204,7 +204,16 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason)) event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) // Service unknown to the autoscaler: the runtime is authoritative —
// reconcile containers directly and persist the replica count.
if runtimeErr := runtimeScaleService(c, serviceID, request.Replicas); runtimeErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "runtime_error": runtimeErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Service scaled successfully",
"state": gin.H{"service_id": serviceID, "desired_replicas": request.Replicas},
})
return return
} }
@@ -214,11 +223,19 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ // Apply the replica change to live containers (best effort — the
// bookkeeping update above already succeeded).
runtimeErr := runtimeScaleService(c, serviceID, request.Replicas)
resp := gin.H{
"message": "Service scaled successfully", "message": "Service scaled successfully",
"event": event, "event": event,
"state": state, "state": state,
}) }
if runtimeErr != nil {
resp["runtime_error"] = runtimeErr.Error()
}
c.JSON(http.StatusOK, resp)
} }
// GetScalingStatus returns the overall status of the auto-scaler // GetScalingStatus returns the overall status of the auto-scaler
+142 -47
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment"
"context"
"net/http" "net/http"
"time" "time"
@@ -24,37 +26,54 @@ type Service struct {
BuildPath string `json:"build_path" db:"build_path"` BuildPath string `json:"build_path" db:"build_path"`
CPU string `json:"cpu" db:"cpu"` CPU string `json:"cpu" db:"cpu"`
Memory string `json:"memory" db:"memory"` Memory string `json:"memory" db:"memory"`
CreatedAt time.Time `json:"created_at" db:"created_at"` // Runtime spec
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` Replicas int `json:"replicas" db:"replicas"`
Port int `json:"port" db:"port"` // container port to expose
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
// CreateServiceRequest represents a request to create a service // CreateServiceRequest represents a request to create a service
type CreateServiceRequest struct { type CreateServiceRequest struct {
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name" binding:"required,min=1,max=255"` Name string `json:"name" binding:"required,min=1,max=255"`
Type string `json:"type" binding:"required,oneof=web worker database cron"` Type string `json:"type" binding:"required,oneof=web worker database cron"`
Image string `json:"image"` Image string `json:"image"`
Command string `json:"command"` Command string `json:"command"`
Environment string `json:"environment" binding:"required,oneof=production preview development"` Environment string `json:"environment" binding:"required,oneof=production preview development"`
GitRepo string `json:"git_repo"` GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"` GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"` BuildPath string `json:"build_path"`
CPU string `json:"cpu"` CPU string `json:"cpu"`
Memory string `json:"memory"` Memory string `json:"memory"`
Replicas int `json:"replicas"`
Port int `json:"port"`
Domain string `json:"domain"`
HealthCheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
} }
// UpdateServiceRequest represents a request to update a service // UpdateServiceRequest represents a request to update a service
type UpdateServiceRequest struct { type UpdateServiceRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=255"` Name string `json:"name" binding:"omitempty,min=1,max=255"`
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"` Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
Image string `json:"image"` Image string `json:"image"`
Command string `json:"command"` Command string `json:"command"`
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"` Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
GitRepo string `json:"git_repo"` GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"` GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"` BuildPath string `json:"build_path"`
CPU string `json:"cpu"` CPU string `json:"cpu"`
Memory string `json:"memory"` Memory string `json:"memory"`
Replicas *int `json:"replicas"`
Port *int `json:"port"`
Domain *string `json:"domain"`
HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
} }
// handleGetServices retrieves all services for a project // handleGetServices retrieves all services for a project
@@ -110,6 +129,9 @@ func handleGetServices(c *gin.Context) {
COALESCE(build_path, ''), COALESCE(build_path, ''),
COALESCE(cpu, ''), COALESCE(cpu, ''),
COALESCE(memory, ''), COALESCE(memory, ''),
COALESCE(replicas, 1), COALESCE(port, 0),
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
COALESCE(restart_policy, 'unless-stopped'),
created_at, updated_at created_at, updated_at
FROM services FROM services
WHERE project_id = $1 WHERE project_id = $1
@@ -129,7 +151,8 @@ func handleGetServices(c *gin.Context) {
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
@@ -138,6 +161,11 @@ func handleGetServices(c *gin.Context) {
services = append(services, service) services = append(services, service)
} }
// Reconcile stored status with live container state (no-op without Docker).
for i := range services {
liveServiceStatus(c, db.(*database.DB), &services[i])
}
c.JSON(http.StatusOK, gin.H{"services": services}) c.JSON(http.StatusOK, gin.H{"services": services})
} }
@@ -213,21 +241,26 @@ func handleCreateService(c *gin.Context) {
// Create new service // Create new service
service := Service{ service := Service{
ID: uuid.New(), ID: uuid.New(),
ProjectID: req.ProjectID, ProjectID: req.ProjectID,
Name: req.Name, Name: req.Name,
Type: req.Type, Type: req.Type,
Status: "stopped", // Initial status Status: "stopped", // Initial status
Image: req.Image, Image: req.Image,
Command: req.Command, Command: req.Command,
Environment: req.Environment, Environment: req.Environment,
GitRepo: req.GitRepo, GitRepo: req.GitRepo,
GitBranch: req.GitBranch, GitBranch: req.GitBranch,
BuildPath: req.BuildPath, BuildPath: req.BuildPath,
CPU: req.CPU, CPU: req.CPU,
Memory: req.Memory, Memory: req.Memory,
CreatedAt: time.Now(), Replicas: req.Replicas,
UpdatedAt: time.Now(), Port: req.Port,
Domain: req.Domain,
HealthCheckPath: req.HealthCheckPath,
RestartPolicy: req.RestartPolicy,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
} }
// Set default values if not provided // Set default values if not provided
@@ -237,6 +270,12 @@ func handleCreateService(c *gin.Context) {
if service.Memory == "" { if service.Memory == "" {
service.Memory = "512Mi" service.Memory = "512Mi"
} }
if service.Replicas < 1 {
service.Replicas = 1
}
if service.RestartPolicy == "" {
service.RestartPolicy = "unless-stopped"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment) environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
if err != nil { if err != nil {
@@ -248,15 +287,17 @@ func handleCreateService(c *gin.Context) {
// Insert service into database // Insert service into database
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`INSERT INTO services `INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name, (id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment, build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at) git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`, healthcheck_path, restart_policy, 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)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type, service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command, "", service.Command, service.Type, service.Status, service.Image, service.Command,
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory, service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
service.CreatedAt, service.UpdatedAt, service.CreatedAt, service.UpdatedAt,
) )
@@ -324,6 +365,9 @@ func handleGetService(c *gin.Context) {
COALESCE(s.build_path, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.cpu, ''),
COALESCE(s.memory, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -333,7 +377,8 @@ func handleGetService(c *gin.Context) {
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -341,6 +386,7 @@ func handleGetService(c *gin.Context) {
return return
} }
liveServiceStatus(c, db.(*database.DB), &service)
c.JSON(http.StatusOK, gin.H{"service": service}) c.JSON(http.StatusOK, gin.H{"service": service})
} }
@@ -386,6 +432,9 @@ func handleUpdateService(c *gin.Context) {
COALESCE(s.build_path, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.cpu, ''),
COALESCE(s.memory, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -396,6 +445,8 @@ func handleUpdateService(c *gin.Context) {
&existingService.Status, &existingService.Image, &existingService.Command, &existingService.Status, &existingService.Image, &existingService.Command,
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch, &existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
&existingService.BuildPath, &existingService.CPU, &existingService.Memory, &existingService.BuildPath, &existingService.CPU, &existingService.Memory,
&existingService.Replicas, &existingService.Port, &existingService.Domain,
&existingService.HealthCheckPath, &existingService.RestartPolicy,
&existingService.CreatedAt, &existingService.UpdatedAt, &existingService.CreatedAt, &existingService.UpdatedAt,
) )
@@ -435,18 +486,37 @@ func handleUpdateService(c *gin.Context) {
if req.Memory != "" { if req.Memory != "" {
existingService.Memory = req.Memory existingService.Memory = req.Memory
} }
if req.Replicas != nil {
existingService.Replicas = *req.Replicas
}
if req.Port != nil {
existingService.Port = *req.Port
}
if req.Domain != nil {
existingService.Domain = *req.Domain
}
if req.HealthCheckPath != nil {
existingService.HealthCheckPath = *req.HealthCheckPath
}
if req.RestartPolicy != "" {
existingService.RestartPolicy = req.RestartPolicy
}
existingService.UpdatedAt = time.Now() existingService.UpdatedAt = time.Now()
// Update service in database // Update service in database
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`UPDATE services `UPDATE services
SET name = $1, type = $2, image = $3, command = $4, environment = $5, SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, updated_at = $11 git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
WHERE id = $12`, replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
restart_policy = $15, updated_at = $16
WHERE id = $17`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.Environment, existingService.GitRepo, existingService.GitBranch,
existingService.BuildPath, existingService.CPU, existingService.Memory, existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy,
existingService.UpdatedAt, existingService.ID, existingService.UpdatedAt, existingService.ID,
) )
@@ -455,6 +525,24 @@ func handleUpdateService(c *gin.Context) {
return return
} }
// A replica change applies immediately when the service has live
// containers; other spec fields take effect on the next deploy.
if req.Replicas != nil && existingService.Status == "running" {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
engine := engineValue.(*deployment.DeploymentEngine)
serviceCopy := existingService
go func() {
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
_, _ = engine.ReconcileService(ctx, spec)
}()
}
}
c.JSON(http.StatusOK, gin.H{"service": existingService}) c.JSON(http.StatusOK, gin.H{"service": existingService})
} }
@@ -496,11 +584,18 @@ func handleDeleteService(c *gin.Context) {
} }
// Check if user owns the project // Check if user owns the project
if projectOwnerID != userID.(string) { if projectOwnerID != userID.(string) && !isAdminUser(db.(*database.DB), userID.(string)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
// Remove runtime containers before dropping the row.
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
_ = engine.RemoveServiceContainers(c.Request.Context(), serviceID.String())
}
}
// Delete service (cascade will handle related records) // Delete service (cascade will handle related records)
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
"DELETE FROM services WHERE id = $1", "DELETE FROM services WHERE id = $1",
+290
View File
@@ -0,0 +1,290 @@
package api
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"strings"
"time"
"containr/internal/database"
"containr/internal/docker"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
)
// Platform setting keys. Values stored in app_settings take precedence over
// environment variables; env remains the fallback for optional overrides.
const (
settingSignupEnabled = "signup_enabled"
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
cloudflaredContainerName = "containr-cloudflared"
cloudflaredImage = "cloudflare/cloudflared:latest"
)
// settingValue resolves a setting: app_settings row first, env fallback,
// 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)
if err == nil {
return value
}
if err != sql.ErrNoRows {
log.Printf("Failed to read setting %s: %v", key, err)
}
}
if envKey != "" {
if value := strings.TrimSpace(os.Getenv(envKey)); value != "" {
return value
}
}
return fallback
}
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
}
func deleteSetting(db *database.DB, key string) error {
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
return err
}
// signupEnabled controls whether public registration stays open after the
// first account exists. Defaults to closed; the platform owner can reopen it
// in Settings or via SIGNUP_ENABLED=true.
func signupEnabled(db *database.DB) bool {
return strings.EqualFold(settingValue(db, settingSignupEnabled, "SIGNUP_ENABLED", "false"), "true")
}
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 {
return false
}
return isAdmin
}
func requireAdmin(c *gin.Context) (*database.DB, bool) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return nil, false
}
db, _ := c.Get("db")
database, _ := db.(*database.DB)
if database == nil || database.DB == nil || !isAdminUser(database, userID) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return nil, false
}
return database, true
}
type cloudflareTunnelStatus struct {
TokenSet bool `json:"token_set"`
Source string `json:"source"` // "app" | "env" | "none"
Container string `json:"container"`
}
func handleGetSettings(c *gin.Context) {
db, ok := requireAdmin(c)
if !ok {
return
}
dockerClient, _ := c.Get("docker_client")
client, _ := dockerClient.(*docker.Client)
c.JSON(http.StatusOK, gin.H{
"signup_enabled": signupEnabled(db),
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
})
}
func handleUpdateSettings(c *gin.Context) {
db, ok := requireAdmin(c)
if !ok {
return
}
var req struct {
SignupEnabled *bool `json:"signup_enabled"`
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.SignupEnabled != nil {
value := "false"
if *req.SignupEnabled {
value = "true"
}
if err := setSetting(db, settingSignupEnabled, value, false); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update signup setting"})
return
}
}
if req.CloudflareTunnelToken != nil {
token := strings.TrimSpace(*req.CloudflareTunnelToken)
if token == "" {
if err := deleteSetting(db, settingCloudflareTunnelToken); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tunnel token"})
return
}
} else if err := setSetting(db, settingCloudflareTunnelToken, token, true); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tunnel token"})
return
}
if dockerClient, exists := c.Get("docker_client"); exists {
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
go applyCloudflaredTunnel(client, token)
}
}
}
handleGetSettings(c)
}
// 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) != "" {
return strings.TrimSpace(value), "app"
}
}
if value := strings.TrimSpace(os.Getenv("CLOUDFLARED_TOKEN")); value != "" && value != "your_cloudflare_tunnel_token_here" {
return value, "env"
}
return "", "none"
}
func cloudflareTunnelState(ctx context.Context, db *database.DB, client *docker.Client) cloudflareTunnelStatus {
token, source := resolvedTunnelToken(db)
state := cloudflareTunnelStatus{TokenSet: token != "", Source: source, Container: "missing"}
if client == nil {
state.Container = "unavailable"
return state
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
container, err := findCloudflaredContainer(ctx, client)
if err != nil || container == "" {
return state
}
if inspect, err := client.GetContainer(ctx, container); err == nil && inspect.State != nil {
state.Container = inspect.State.Status
}
return state
}
// syncManagedContainers reconciles containers the platform manages from
// settings - currently only the optional Cloudflare Tunnel sidecar.
func syncManagedContainers(client *docker.Client, db *database.DB) {
token, _ := resolvedTunnelToken(db)
if token == "" {
return
}
go applyCloudflaredTunnel(client, token)
}
func findCloudflaredContainer(ctx context.Context, client *docker.Client) (string, error) {
containers, err := client.ListContainers(ctx, true)
if err != nil {
return "", err
}
for _, item := range containers {
for _, name := range item.Names {
if strings.TrimPrefix(name, "/") == cloudflaredContainerName {
return item.ID, nil
}
}
}
return "", nil
}
// applyCloudflaredTunnel ensures a cloudflared container mirrors the stored
// token: created/recreated when set, removed when cleared. It joins the same
// networks as the backend so tunnel targets can use compose service names.
func applyCloudflaredTunnel(client *docker.Client, token string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if existing, err := findCloudflaredContainer(ctx, client); err == nil && existing != "" {
if err := client.RemoveContainer(ctx, existing, true); err != nil {
log.Printf("Failed to remove existing cloudflared container: %v", err)
return
}
}
if token == "" {
return
}
if err := client.PullImageWait(ctx, cloudflaredImage, registry.AuthConfig{}); err != nil {
log.Printf("Failed to pull %s: %v", cloudflaredImage, err)
}
networks, err := backendNetworks(ctx, client)
if err != nil {
log.Printf("Failed to resolve backend networks for cloudflared: %v", err)
return
}
id, err := client.CreateContainer(ctx, docker.ContainerConfig{
Name: cloudflaredContainerName,
Image: cloudflaredImage,
Cmd: []string{"tunnel", "--no-autoupdate", "run", "--token", token},
RestartPolicy: "unless-stopped",
Labels: map[string]string{"containr.managed": "cloudflared"},
Networks: networks,
})
if err != nil {
log.Printf("Failed to create cloudflared container: %v", err)
return
}
if err := client.StartContainer(ctx, id); err != nil {
log.Printf("Failed to start cloudflared container: %v", err)
}
}
// backendNetworks returns the networks this backend container sits on, so the
// managed cloudflared container can reach the frontend/backend by name.
func backendNetworks(ctx context.Context, client *docker.Client) (map[string]*network.EndpointSettings, error) {
hostname, err := os.Hostname()
if err != nil {
return nil, err
}
self, err := client.GetContainer(ctx, hostname)
if err != nil {
return nil, err
}
networks := map[string]*network.EndpointSettings{}
for name := range self.NetworkSettings.Networks {
networks[name] = &network.EndpointSettings{}
}
if len(networks) == 0 {
return nil, sql.ErrNoRows
}
return networks, nil
}
+5 -5
View File
@@ -93,10 +93,10 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
return nil, fmt.Errorf("prebuilt image not specified") return nil, fmt.Errorf("prebuilt image not specified")
} }
// Pull the prebuilt image // Pull the prebuilt image — must drain the pull stream or the daemon
// aborts it and the tag below races a missing image.
auth := registry.AuthConfig{} auth := registry.AuthConfig{}
_, err := bm.dockerClient.PullImage(ctx, req.PrebuiltImage, auth) if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
if err != nil {
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err) return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
} }
@@ -104,7 +104,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
if req.ImageName != "" && req.ImageTag != "" { if req.ImageName != "" && req.ImageTag != "" {
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag) targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
if targetImage != req.PrebuiltImage { if targetImage != req.PrebuiltImage {
err = bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage) err := bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to tag image: %w", err) return nil, fmt.Errorf("failed to tag image: %w", err)
} }
@@ -114,7 +114,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
// Push to registry if specified // Push to registry if specified
if req.RegistryURL != "" { if req.RegistryURL != "" {
err = bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL) err := bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to push image: %w", err) return nil, fmt.Errorf("failed to push image: %w", err)
} }
+2 -3
View File
@@ -215,9 +215,8 @@ func (c *Config) Validate() error {
return fmt.Errorf("SEED_DATA_ON_START must be false in production") return fmt.Errorf("SEED_DATA_ON_START must be false in production")
} }
if !c.CookieSecure { // COOKIE_SECURE=false is legal for plain-HTTP self-hosted installs and is
return fmt.Errorf("COOKIE_SECURE must be true in production") // reported as a startup warning in main.go rather than a fatal error.
}
if c.BetterAuthEnabled { if c.BetterAuthEnabled {
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 || if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
+5 -4
View File
@@ -108,7 +108,7 @@ func TestValidateAllowsWildcardCorsWithoutCredentialsInProduction(t *testing.T)
} }
} }
func TestValidateRejectsInsecureCookieInProduction(t *testing.T) { func TestValidateAllowsInsecureCookieInProduction(t *testing.T) {
t.Setenv("ENVIRONMENT", "production") t.Setenv("ENVIRONMENT", "production")
cfg := Config{ cfg := Config{
JWTSecret: "this-is-a-very-strong-production-secret-123", JWTSecret: "this-is-a-very-strong-production-secret-123",
@@ -120,9 +120,10 @@ func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
CORSCredentials: true, CORSCredentials: true,
} }
err := cfg.Validate() // Plain-HTTP self-hosted installs are a supported production deployment;
if err == nil || !strings.Contains(err.Error(), "COOKIE_SECURE must be true") { // insecure cookies are allowed and surfaced as a startup warning.
t.Fatalf("expected COOKIE_SECURE validation error, got %v", err) if err := cfg.Validate(); err != nil {
t.Fatalf("expected production config with insecure cookies to validate, got %v", err)
} }
} }
+41 -76
View File
@@ -9,10 +9,6 @@ import (
"containr/internal/build" "containr/internal/build"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/types" "containr/internal/types"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat"
) )
type DeploymentEngine struct { type DeploymentEngine struct {
@@ -56,6 +52,13 @@ type ServiceConfig struct {
Resources ResourceLimits `json:"resources,omitempty"` Resources ResourceLimits `json:"resources,omitempty"`
HealthCheck *HealthCheck `json:"health_check,omitempty"` HealthCheck *HealthCheck `json:"health_check,omitempty"`
Replicas int `json:"replicas"` Replicas int `json:"replicas"`
// PublicPort is the container port exposed to users (published host port,
// or Traefik routing when Domain is set). 0 = internal-only service.
PublicPort int32 `json:"public_port,omitempty"`
// Domain is a public hostname routed to PublicPort via Traefik.
Domain string `json:"domain,omitempty"`
// HealthPath is an HTTP path probed on PublicPort for container health.
HealthPath string `json:"health_path,omitempty"`
} }
type PortMapping struct { type PortMapping struct {
@@ -297,91 +300,53 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
return response.ImageName, nil return response.ImageName, nil
} }
// deployService deploys the service using the built image // deployService deploys the service using the built image. Containers are
// reconciled onto the project network with a DNS alias per service name —
// other services reach it at http://<name>:<port>.
func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error { func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error {
// Convert service config to Docker container config cfg := deployment.Config
containerConfig := &docker.ContainerConfig{
Name: fmt.Sprintf("containr-%s-%s", deployment.ServiceID, deployment.ID), spec := RuntimeSpec{
Image: deployment.ImageName, ProjectID: deployment.ProjectID,
Cmd: deployment.Config.Command, ServiceID: deployment.ServiceID,
Labels: deployment.Config.Labels, Name: cfg.Name,
Networks: make(map[string]*network.EndpointSettings), Image: deployment.ImageName,
Command: cfg.Command,
Env: cfg.Environment,
Replicas: cfg.Replicas,
Port: cfg.PublicPort,
Domain: cfg.Domain,
HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy,
MemoryBytes: cfg.Resources.MemoryBytes,
NanoCPUs: cfg.Resources.CPUQuota,
} }
for _, pm := range cfg.PortMappings {
// Set environment variables if spec.Port == 0 {
for k, v := range deployment.Config.Environment { spec.Port = pm.ContainerPort
containerConfig.Env = append(containerConfig.Env, fmt.Sprintf("%s=%s", k, v))
}
// Set restart policy
containerConfig.RestartPolicy = deployment.Config.RestartPolicy
// Configure port mappings
portBindings := make(nat.PortMap)
for _, pm := range deployment.Config.PortMappings {
port := nat.Port(fmt.Sprintf("%d/%s", pm.ContainerPort, pm.Protocol))
if pm.HostPort > 0 {
portBindings[port] = []nat.PortBinding{
{
HostIP: pm.HostIP,
HostPort: fmt.Sprintf("%d", pm.HostPort),
},
}
} }
} }
containerConfig.PortBindings = portBindings if spec.RestartPolicy == "" {
spec.RestartPolicy = "unless-stopped"
// Configure resource limits
if deployment.Config.Resources.MemoryBytes > 0 {
containerConfig.Memory = deployment.Config.Resources.MemoryBytes
}
if deployment.Config.Resources.CPUQuota > 0 {
containerConfig.NanoCPUs = deployment.Config.Resources.CPUQuota
} }
// Configure volume mounts // Volume mounts aren't reconciled replicas-aware yet; still applied on the
for _, vm := range deployment.Config.VolumeMounts { // container config of every replica via spec extension later if needed.
mount := mount.Mount{ state, err := de.ReconcileService(ctx, spec)
Type: mount.Type(vm.Type), if err != nil {
Source: vm.Source, return err
Target: vm.Destination,
ReadOnly: vm.ReadOnly,
}
containerConfig.Mounts = append(containerConfig.Mounts, mount)
} }
// Create containers based on replica count deployment.Containers = make([]ContainerInfo, len(state.Containers))
deployment.Containers = make([]ContainerInfo, deployment.Config.Replicas) for i, c := range state.Containers {
for i := 0; i < deployment.Config.Replicas; i++ {
containerName := fmt.Sprintf("%s-%d", containerConfig.Name, i)
// Create container
containerID, err := de.dockerClient.CreateContainer(ctx, *containerConfig)
if err != nil {
return fmt.Errorf("failed to create container %d: %w", i, err)
}
// Start container
err = de.dockerClient.StartContainer(ctx, containerID)
if err != nil {
return fmt.Errorf("failed to start container %d: %w", i, err)
}
// Get container info
_, err = de.dockerClient.GetContainer(ctx, containerID)
if err != nil {
log.Printf("Failed to get container info for %s: %v", containerID, err)
}
deployment.Containers[i] = ContainerInfo{ deployment.Containers[i] = ContainerInfo{
ID: containerID, ID: c.ID,
Name: containerName, Name: c.Name,
Status: "running", Status: c.State,
CreatedAt: time.Now(), CreatedAt: time.Now(),
StartedAt: time.Now(), StartedAt: time.Now(),
} }
} }
return nil return nil
} }
+382
View File
@@ -0,0 +1,382 @@
package deployment
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"containr/internal/docker"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat"
)
// Container labels used to associate runtime containers with services.
const (
LabelManaged = "containr.managed"
LabelProject = "containr.project"
LabelService = "containr.service"
LabelReplica = "containr.replica"
LabelSpecHash = "containr.spec-hash"
LabelDeployment = "containr.deployment"
)
// RuntimeSpec is everything needed to run a service's containers.
type RuntimeSpec struct {
ProjectID string
ServiceID string
Name string // DNS alias on the project network
Image string
Command []string
Env map[string]string // already resolved
Replicas int
Port int32 // container port to expose publicly; 0 = none
Domain string // public hostname routed via Traefik; empty = none
HealthPath string // http path probed on Port for container healthcheck
RestartPolicy string
MemoryBytes int64
NanoCPUs int64
}
// RuntimeContainer describes one live replica.
type RuntimeContainer struct {
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"` // running, exited, ...
Replica int `json:"replica"`
}
// RuntimeState is the live view of a service's containers.
type RuntimeState struct {
Desired int `json:"desired"`
Containers []RuntimeContainer `json:"containers"`
URLs []string `json:"urls"`
Status string `json:"status"` // running, degraded, stopped
}
func projectNetworkName(projectID string) string {
short := strings.ReplaceAll(projectID, "-", "")
if len(short) > 12 {
short = short[:12]
}
return "containr-proj-" + short
}
func serviceNamePrefix(serviceID string) string {
return fmt.Sprintf("containr-%s-", serviceID)
}
// EnsureProjectNetwork creates the per-project bridge network if missing.
// Containers on it resolve each other by service name (private networking).
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
name := projectNetworkName(projectID)
networks, err := de.dockerClient.ListNetworks(ctx)
if err != nil {
return "", err
}
for _, n := range networks {
if n.Name == name {
return name, nil
}
}
_, err = de.dockerClient.CreateNetwork(ctx, docker.NetworkConfig{
Name: name,
Driver: "bridge",
Labels: map[string]string{
LabelManaged: "true",
LabelProject: projectID,
},
})
if err != nil {
// Concurrent reconciles race on create; re-resolve before failing.
networks, lerr := de.dockerClient.ListNetworks(ctx)
if lerr == nil {
for _, n := range networks {
if n.Name == name {
return name, nil
}
}
}
return "", fmt.Errorf("create project network: %w", err)
}
return name, nil
}
// traefikNetworkName finds the shared edge network (the infra stack's
// containr-network where Traefik listens) if it exists.
func (de *DeploymentEngine) traefikNetworkName(ctx context.Context) string {
networks, err := de.dockerClient.ListNetworks(ctx)
if err != nil {
return ""
}
for _, n := range networks {
if n.Name == "containr-network" {
return n.Name
}
}
return ""
}
// ListServiceContainers returns containers managed for a service.
func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID string) ([]container.Summary, error) {
return de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("label", LabelService+"="+serviceID),
), true)
}
// ReconcileService brings live containers in line with the spec. When the
// spec hash changed (image/env/ports/etc.) all replicas are recreated;
// otherwise only the replica delta is applied.
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
if spec.Replicas < 1 {
spec.Replicas = 1
}
networkName, err := de.EnsureProjectNetwork(ctx, spec.ProjectID)
if err != nil {
return nil, err
}
edgeNetwork := ""
if spec.Domain != "" {
edgeNetwork = de.traefikNetworkName(ctx)
}
hash := specHash(spec)
existing, err := de.ListServiceContainers(ctx, spec.ServiceID)
if err != nil {
return nil, err
}
// Deterministic order: scale-down removes the highest replica indices.
sort.Slice(existing, func(i, j int) bool {
return replicaIndex(existing[i]) < replicaIndex(existing[j])
})
drifted := false
for _, c := range existing {
if c.Labels[LabelSpecHash] != hash {
drifted = true
break
}
}
if drifted {
for _, c := range existing {
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
}
existing = nil
}
for i := len(existing); i > spec.Replicas; i-- {
victim := existing[i-1]
_ = de.dockerClient.StopContainer(ctx, victim.ID, nil)
_ = de.dockerClient.RemoveContainer(ctx, victim.ID, true)
existing = existing[:i-1]
}
for i := len(existing); i < spec.Replicas; i++ {
if _, err := de.createReplica(ctx, spec, networkName, edgeNetwork, i, hash); err != nil {
return nil, fmt.Errorf("create replica %d: %w", i, err)
}
}
for _, c := range existing {
if c.State != "running" {
_ = de.dockerClient.StartContainer(ctx, c.ID)
}
}
return de.RuntimeState(ctx, spec.ServiceID, spec.Replicas)
}
func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, projectNet, edgeNet string, index int, hash string) (string, error) {
env := make([]string, 0, len(spec.Env))
for k, v := range spec.Env {
env = append(env, k+"="+v)
}
sort.Strings(env)
labels := map[string]string{
LabelManaged: "true",
LabelProject: spec.ProjectID,
LabelService: spec.ServiceID,
LabelReplica: fmt.Sprintf("%d", index),
LabelSpecHash: hash,
}
endpoints := map[string]*network.EndpointSettings{
projectNet: {Aliases: []string{spec.Name}},
}
if edgeNet != "" {
endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true"
labels["traefik.docker.network"] = edgeNet
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
}
cfg := docker.ContainerConfig{
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
Image: spec.Image,
Cmd: spec.Command,
Env: env,
Labels: labels,
RestartPolicy: spec.RestartPolicy,
Memory: spec.MemoryBytes,
NanoCPUs: spec.NanoCPUs,
Networks: endpoints,
}
if spec.Port > 0 {
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
cfg.ExposedPorts = nat.PortSet{port: struct{}{}}
// Always publish an ephemeral host port for direct access; a configured
// domain is additionally routed via Traefik when the edge network exists.
cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}}
if spec.HealthPath != "" {
cfg.Healthcheck = &container.HealthConfig{
Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)},
Interval: 30 * time.Second,
Timeout: 5 * time.Second,
Retries: 3,
StartPeriod: 10 * time.Second,
}
}
}
id, err := de.dockerClient.CreateContainer(ctx, cfg)
if err != nil {
return "", err
}
if err := de.dockerClient.StartContainer(ctx, id); err != nil {
return "", fmt.Errorf("start: %w", err)
}
return id, nil
}
// RuntimeState reads live container state for a service.
func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) {
containers, err := de.ListServiceContainers(ctx, serviceID)
if err != nil {
return nil, err
}
state := &RuntimeState{Desired: desired, Status: "stopped"}
running := 0
for _, c := range containers {
rc := RuntimeContainer{ID: c.ID, Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State, Replica: replicaIndex(c)}
state.Containers = append(state.Containers, rc)
if c.State == "running" {
running++
}
for _, p := range c.Ports {
if p.PublicPort != 0 {
state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort))
}
}
}
sort.Slice(state.Containers, func(i, j int) bool { return state.Containers[i].Replica < state.Containers[j].Replica })
sort.Strings(state.URLs)
switch {
case running == 0:
state.Status = "stopped"
case running < desired:
state.Status = "degraded"
default:
state.Status = "running"
}
return state, nil
}
// StopService stops (but keeps) all replicas of a service.
func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error {
containers, err := de.ListServiceContainers(ctx, serviceID)
if err != nil {
return err
}
for _, c := range containers {
if c.State == "running" {
if err := de.dockerClient.StopContainer(ctx, c.ID, nil); err != nil {
return err
}
}
}
return nil
}
// StartService starts all stopped replicas.
func (de *DeploymentEngine) StartService(ctx context.Context, serviceID string) error {
containers, err := de.ListServiceContainers(ctx, serviceID)
if err != nil {
return err
}
for _, c := range containers {
if c.State != "running" {
if err := de.dockerClient.StartContainer(ctx, c.ID); err != nil {
return err
}
}
}
return nil
}
// RemoveServiceContainers deletes every replica of a service.
func (de *DeploymentEngine) RemoveServiceContainers(ctx context.Context, serviceID string) error {
containers, err := de.ListServiceContainers(ctx, serviceID)
if err != nil {
return err
}
for _, c := range containers {
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
}
return nil
}
// RemoveProjectContainers deletes every managed container in a project.
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("label", LabelProject+"="+projectID),
), true)
if err != nil {
return err
}
for _, c := range containers {
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
}
return nil
}
// RemoveProjectNetwork removes the per-project bridge network if present.
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID))
}
func replicaIndex(c container.Summary) int {
replica := 0
fmt.Sscanf(c.Labels[LabelReplica], "%d", &replica)
return replica
}
func specHash(spec RuntimeSpec) string {
payload, _ := json.Marshal(struct {
Image, Name, Domain, HealthPath, RestartPolicy string
Command []string
Env map[string]string
Port int32
MemoryBytes, NanoCPUs int64
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs})
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:8])
}
+41 -1
View File
@@ -3,6 +3,7 @@ package docker
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"time" "time"
@@ -10,6 +11,7 @@ import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
@@ -50,6 +52,14 @@ func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Containe
}) })
} }
// ListContainersFiltered returns containers matching the given filters.
func (c *Client) ListContainersFiltered(ctx context.Context, f filters.Args, all bool) ([]types.Container, error) {
return c.cli.ContainerList(ctx, container.ListOptions{
All: all,
Filters: f,
})
}
// GetContainer returns detailed information about a specific container // GetContainer returns detailed information about a specific container
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) { func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
return c.cli.ContainerInspect(ctx, containerID) return c.cli.ContainerInspect(ctx, containerID)
@@ -63,6 +73,7 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
Env: config.Env, Env: config.Env,
Labels: config.Labels, Labels: config.Labels,
ExposedPorts: config.ExposedPorts, ExposedPorts: config.ExposedPorts,
Healthcheck: config.Healthcheck,
} }
hostConfig := &container.HostConfig{ hostConfig := &container.HostConfig{
@@ -168,7 +179,9 @@ func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, err
}) })
} }
// PullImage pulls an image from a registry // PullImage pulls an image from a registry. The pull runs as the returned
// stream is consumed — callers must drain and close it (or use
// PullImageWait) or the pull may be aborted.
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) { func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
authStr, _ := registry.EncodeAuthConfig(auth) authStr, _ := registry.EncodeAuthConfig(auth)
return c.cli.ImagePull(ctx, ref, image.PullOptions{ return c.cli.ImagePull(ctx, ref, image.PullOptions{
@@ -176,6 +189,33 @@ func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthCo
}) })
} }
// PullImageWait pulls an image and blocks until the pull stream completes.
func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.AuthConfig) error {
reader, err := c.PullImage(ctx, ref, auth)
if err != nil {
return err
}
defer reader.Close()
// The daemon performs the pull while streaming progress; drain it fully
// and surface any errorDetail the daemon reports in the stream.
dec := json.NewDecoder(reader)
var msg struct {
ErrorDetail *struct {
Message string `json:"message"`
} `json:"errorDetail"`
Error string `json:"error"`
}
for dec.Decode(&msg) == nil {
if msg.ErrorDetail != nil {
return fmt.Errorf("%s", msg.ErrorDetail.Message)
}
if msg.Error != "" {
return fmt.Errorf("%s", msg.Error)
}
}
return nil
}
// BuildImage builds an image from a Dockerfile // BuildImage builds an image from a Dockerfile
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) { func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{ return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
+2
View File
@@ -3,6 +3,7 @@ package docker
import ( import (
"time" "time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
@@ -24,6 +25,7 @@ type ContainerConfig struct {
NanoCPUs int64 NanoCPUs int64
NetworkMode string NetworkMode string
Networks map[string]*network.EndpointSettings Networks map[string]*network.EndpointSettings
Healthcheck *container.HealthConfig
} }
// LogOptions represents options for retrieving container logs // LogOptions represents options for retrieving container logs
+10 -3
View File
@@ -248,11 +248,18 @@ func ensureLocalUserRecord(c *gin.Context, user betterAuthSessionUser) (string,
return "", hashErr return "", hashErr
} }
// The first mirrored account owns the platform.
var userCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
return "", err
}
isFirstUser := userCount == 0
if err := db.QueryRow(` if err := db.QueryRow(`
INSERT INTO users (email, password_hash, name, avatar_url) INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4, '')) VALUES ($1, $2, $3, NULLIF($4, ''), $5)
RETURNING id RETURNING id
`, email, string(hashedPassword), name, avatarURL).Scan(&localUserID); err != nil { `, email, string(hashedPassword), name, avatarURL, isFirstUser).Scan(&localUserID); err != nil {
return "", err return "", err
} }
@@ -0,0 +1,24 @@
-- +goose Up
-- In-app platform settings. Values here override environment variables;
-- secrets (e.g. Cloudflare tunnel token) are flagged and masked in API reads.
CREATE TABLE IF NOT EXISTS public.app_settings (
key character varying(255) PRIMARY KEY,
value text NOT NULL DEFAULT '',
is_secret boolean NOT NULL DEFAULT false,
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
-- The first registered user owns the platform. Backfill existing installs:
-- the earliest account becomes admin when no admin is set.
UPDATE public.users
SET is_admin = true
WHERE id = (
SELECT id FROM public.users ORDER BY created_at ASC, id ASC LIMIT 1
)
AND NOT EXISTS (SELECT 1 FROM public.users WHERE is_admin);
-- +goose Down
ALTER TABLE public.users DROP COLUMN IF EXISTS is_admin;
DROP TABLE IF EXISTS public.app_settings;
@@ -0,0 +1,16 @@
-- +goose Up
-- Runtime spec for Railway-style services: replica count, the container port
-- to expose, an optional public domain routed via Traefik, healthcheck path
-- and restart policy.
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS replicas integer NOT NULL DEFAULT 1;
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS port integer NOT NULL DEFAULT 0;
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS domain character varying(500) NOT NULL DEFAULT '';
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS healthcheck_path character varying(500) NOT NULL DEFAULT '';
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS restart_policy character varying(50) NOT NULL DEFAULT 'unless-stopped';
-- +goose Down
ALTER TABLE public.services DROP COLUMN IF EXISTS replicas;
ALTER TABLE public.services DROP COLUMN IF EXISTS port;
ALTER TABLE public.services DROP COLUMN IF EXISTS domain;
ALTER TABLE public.services DROP COLUMN IF EXISTS healthcheck_path;
ALTER TABLE public.services DROP COLUMN IF EXISTS restart_policy;
+5 -4
View File
@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM node:20-alpine AS builder FROM node:22-alpine AS builder
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -13,9 +13,10 @@ RUN npm ci
# Copy source code # Copy source code
COPY . . COPY . .
# Build-time API URL injection for Vite # Build-time API URL injection for Vite. Empty = same-origin via the nginx
ARG VITE_API_URL=http://localhost:8082 # /api/ proxy, which keeps the published image usable from any host.
ARG VITE_AUTH_URL=http://localhost:8082/api/auth ARG VITE_API_URL=
ARG VITE_AUTH_URL=
ENV VITE_API_URL=${VITE_API_URL} ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_AUTH_URL=${VITE_AUTH_URL} ENV VITE_AUTH_URL=${VITE_AUTH_URL}
+22
View File
@@ -21,6 +21,12 @@ http {
keepalive_timeout 65; keepalive_timeout 65;
types_hash_max_size 2048; types_hash_max_size 2048;
# WebSocket upgrade support
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Gzip compression # Gzip compression
gzip on; gzip on;
gzip_vary on; gzip_vary on;
@@ -51,6 +57,22 @@ http {
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Same-origin API proxy - keeps the published image host-agnostic
location /api/ {
resolver 127.0.0.11 valid=10s;
set $backend_upstream http://backend:8080;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Handle React Router # Handle React Router
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+46 -26
View File
@@ -114,6 +114,7 @@ export function SignInPage() {
queryKey: ['auth-bootstrap'], queryKey: ['auth-bootstrap'],
queryFn: getAuthBootstrap, queryFn: getAuthBootstrap,
}); });
const providers = bootstrapQuery.data?.providers ?? [];
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -168,10 +169,14 @@ export function SignInPage() {
return ( return (
<AuthLayout> <AuthLayout>
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google."> <AuthCard
title="Sign In"
subtitle={providers.length > 0 ? 'Use email/password or a connected provider.' : 'Use your email and password.'}
>
{error ? <AuthErrorNotice message={error} /> : null} {error ? <AuthErrorNotice message={error} /> : null}
{info ? <AuthInfoNotice message={info} /> : null} {info ? <AuthInfoNotice message={info} /> : null}
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null} {bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
<form className="space-y-3" onSubmit={submitEmailPassword}> <form className="space-y-3" onSubmit={submitEmailPassword}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -211,30 +216,38 @@ export function SignInPage() {
</button> </button>
</form> </form>
<div className="my-5 flex items-center gap-3"> {providers.length > 0 ? (
<div className="h-px flex-1 bg-[var(--border-subtle)]" /> <>
<span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span> <div className="my-5 flex items-center gap-3">
<div className="h-px flex-1 bg-[var(--border-subtle)]" /> <div className="h-px flex-1 bg-[var(--border-subtle)]" />
</div> <span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span>
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
</div>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
<button {providers.includes('github') ? (
type="button" <button
onClick={() => void signInWithGitHubProvider()} type="button"
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]" onClick={() => void signInWithGitHubProvider()}
> className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
<Github size={15} /> >
GitHub <Github size={15} />
</button> GitHub
<button </button>
type="button" ) : null}
onClick={() => void signInWithGoogleProvider()} {providers.includes('google') ? (
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]" <button
> type="button"
<GoogleMark /> onClick={() => void signInWithGoogleProvider()}
Google className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
</button> >
</div> <GoogleMark />
Google
</button>
) : null}
</div>
</>
) : null}
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]"> <div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>Need access?</span> <span>Need access?</span>
@@ -266,7 +279,8 @@ export function SignUpPage() {
return <Navigate to={redirectPath} replace />; return <Navigate to={redirectPath} replace />;
} }
if (bootstrapQuery.data?.mode === 'login') { // Registration stays available when the owner reopened it in Settings.
if (bootstrapQuery.data?.mode === 'login' && !bootstrapQuery.data.signupEnabled) {
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />; return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
} }
@@ -289,9 +303,15 @@ export function SignUpPage() {
return ( return (
<AuthLayout> <AuthLayout>
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap."> <AuthCard
title="Create Account"
subtitle={bootstrapQuery.data?.mode === 'register'
? 'Create first platform owner account. Registration closes after bootstrap.'
: 'Create a new account.'}
>
{error ? <AuthErrorNotice message={error} /> : null} {error ? <AuthErrorNotice message={error} /> : null}
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null} {bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
<form className="space-y-3" onSubmit={submitSignUp}> <form className="space-y-3" onSubmit={submitSignUp}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
createProject, createProject,
getHostMonitoring, getHostMonitoring,
listBuilds, listRecentDeployments,
listProjects, listProjects,
type ProjectEntity, type ProjectEntity,
type ProjectStats, type ProjectStats,
@@ -156,10 +156,10 @@ export function ProjectsPage() {
queryFn: getHostMonitoring, queryFn: getHostMonitoring,
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
const buildsQuery = useQuery({ const deploysQuery = useQuery({
queryKey: ['recent-deploys'], queryKey: ['recent-deploys'],
enabled: !isDemoMode, enabled: !isDemoMode,
queryFn: () => listBuilds({ page: 1, limit: 5 }), queryFn: () => listRecentDeployments(10),
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
@@ -193,16 +193,16 @@ export function ProjectsPage() {
const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null; const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null;
const diskPct = isDemoMode ? 73 : host ? Math.round(host.storage.usagePercent) : null; const diskPct = isDemoMode ? 73 : host ? Math.round(host.storage.usagePercent) : null;
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`; const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const builds = isDemoMode ? [] : buildsQuery.data?.builds ?? []; const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
const deployCount = buildsQuery.data?.total ?? builds.length; const deployCount = deployments.length;
const feed = isDemoMode const feed = isDemoMode
? demoDeploys ? demoDeploys
: builds.map((b) => ({ : deployments.slice(0, 5).map((d) => ({
id: b.id, id: d.id,
name: b.imageName || b.serviceId || b.id, name: d.serviceName || d.imageName || d.serviceId || d.id,
project: '', project: d.projectName,
status: (b.status || 'queued').toUpperCase(), status: (d.status || 'queued').toUpperCase(),
when: formatRelative(b.startedAt ?? b.completedAt), when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
})); }));
const statusClass = (status: string) => const statusClass = (status: string) =>
@@ -278,7 +278,7 @@ export function ProjectsPage() {
<div className="v-k"><span>DEPLOYS</span></div> <div className="v-k"><span>DEPLOYS</span></div>
<div className="v-v">{isDemoMode ? 18 : deployCount}</div> <div className="v-v">{isDemoMode ? 18 : deployCount}</div>
<div className="v-meter"><i style={{ width: '94%' }} /></div> <div className="v-meter"><i style={{ width: '94%' }} /></div>
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${builds.filter((b) => (b.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div> <div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
</div> </div>
</div> </div>
@@ -29,6 +29,8 @@ import {
getGitHubAppInstallUrl, getGitHubAppInstallUrl,
connectGitHubApp, connectGitHubApp,
updateCurrentUserProfile, updateCurrentUserProfile,
getPlatformSettings,
updatePlatformSettings,
getHAStatus, getHAStatus,
setHAEnabled, setHAEnabled,
triggerFailover, triggerFailover,
@@ -92,6 +94,8 @@ import {
ShieldCheck, ShieldCheck,
Zap, Zap,
HeartPulse, HeartPulse,
Cloud,
ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) { function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
@@ -1077,6 +1081,144 @@ function GitProvidersSection() {
); );
} }
function PlatformSettingsSection() {
const queryClient = useQueryClient();
const settingsQuery = useQuery({
queryKey: ['platform-settings'],
queryFn: getPlatformSettings,
});
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
const [tokenDraft, setTokenDraft] = useState('');
const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const signupEnabled = signupDraft ?? settingsQuery.data?.signupEnabled ?? false;
const tunnel = settingsQuery.data?.cloudflareTunnel;
const saveMutation = useMutation({
mutationFn: updatePlatformSettings,
onSuccess: (settings) => {
queryClient.setQueryData(['platform-settings'], settings);
setSignupDraft(null);
setTokenDraft('');
setFeedback('Settings saved.');
setError(null);
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Failed to save settings');
setFeedback(null);
},
});
const saveSignup = (enabled: boolean) => {
setSignupDraft(enabled);
saveMutation.mutate({ signupEnabled: enabled });
};
const saveToken = () => {
if (!tokenDraft.trim()) return;
saveMutation.mutate({ cloudflareTunnelToken: tokenDraft.trim() });
};
const clearToken = () => {
saveMutation.mutate({ cloudflareTunnelToken: '' });
};
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Cloud size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2>
<p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p>
</div>
</div>
{settingsQuery.isLoading ? (
<div className="py-6 text-center">
<Loader2 size={18} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : (
<div className="space-y-4">
<label className="flex items-start gap-3 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] cursor-pointer">
<input
type="checkbox"
checked={signupEnabled}
onChange={(e) => saveSignup(e.target.checked)}
disabled={saveMutation.isPending}
className="mt-0.5 accent-[var(--accent-primary)]"
/>
<div>
<span className="text-sm font-medium text-[var(--text-primary)]">Open registration</span>
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
Let anyone with the URL create an account. Off by default after the owner account is created.
</p>
</div>
</label>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Cloudflare Tunnel</span>
<span className="text-xs text-[var(--text-tertiary)]">
{tunnel?.tokenSet
? `token saved (${tunnel.source}) - ${tunnel.container}`
: tunnel?.container === 'unavailable'
? 'Docker socket unavailable'
: 'not configured'}
</span>
</div>
<p className="text-xs text-[var(--text-secondary)] mb-3">
Expose this instance through a Cloudflare Tunnel. Create a tunnel in the dashboard,
copy the token, paste it here - the cloudflared sidecar is managed for you.
</p>
<div className="flex gap-2">
<input
type="password"
value={tokenDraft}
onChange={(e) => setTokenDraft(e.target.value)}
className="flex-1 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder={tunnel?.tokenSet ? 'Token saved - paste a new one to rotate' : 'eyJhIjo... tunnel token'}
/>
<button
onClick={saveToken}
disabled={saveMutation.isPending || !tokenDraft.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
>
Save
</button>
{tunnel?.tokenSet && tunnel.source === 'app' ? (
<button
onClick={clearToken}
disabled={saveMutation.isPending}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
>
Remove
</button>
) : null}
</div>
<a
href="https://one.dash.cloudflare.com/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 mt-3 text-xs text-[var(--accent-primary)] hover:underline"
>
Open Cloudflare Zero Trust dashboard - Networks → Tunnels → Create
<ExternalLink size={12} />
</a>
</div>
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
</div>
)}
</section>
);
}
export function SettingsPage() { export function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -1238,6 +1380,9 @@ export function SettingsPage() {
)} )}
</section> </section>
{/* Platform Settings - owner only */}
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
{/* Git Providers Section */} {/* Git Providers Section */}
<GitProvidersSection /> <GitProvidersSection />
@@ -29,6 +29,10 @@ import {
execInService, execInService,
rollbackDeployment, rollbackDeployment,
updateServiceVariables, updateServiceVariables,
updateService,
restartService,
stopService,
getServiceRuntime,
type CronJobEntity, type CronJobEntity,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
@@ -284,7 +288,11 @@ export function ServiceDetailPage() {
}); });
const manualScaleMutation = useMutation({ const manualScaleMutation = useMutation({
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas), mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
onSuccess: invalidateScaling, onSuccess: () => {
invalidateScaling();
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services'] });
},
}); });
// --- Console --- // --- Console ---
@@ -365,6 +373,39 @@ export function ServiceDetailPage() {
}, },
}); });
const invalidateService = () => {
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
};
const restartMutation = useMutation({
mutationFn: () => restartService(serviceId),
onSuccess: invalidateService,
});
const stopMutation = useMutation({
mutationFn: () => stopService(serviceId),
onSuccess: invalidateService,
});
const updateServiceMutation = useMutation({
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
onSuccess: () => {
setNetworkForm(null);
invalidateService();
},
});
const runtimeQuery = useQuery({
queryKey: ['service-runtime', serviceId],
queryFn: () => getServiceRuntime(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
refetchInterval: 10_000,
});
const [networkForm, setNetworkForm] = useState<{
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
} | null>(null);
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
@@ -552,21 +593,23 @@ export function ServiceDetailPage() {
<div> <div>
<div className="flex items-center" style={{ gap: '10px' }}> <div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span> <span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
<span className={`badge-${service.status === 'running' ? 'active' : 'stopped'}`}> <span
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
>
{service.status === 'running' && <span className="live-dot" />} {service.status === 'running' && <span className="live-dot" />}
{service.status === 'running' ? 'Active' : 'Stopped'} {service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
</span> </span>
</div> </div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}> <div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
{service.status === 'running' && ( {(service.publicUrl || service.domain) && (
<a <a
href={`https://${service.name}.containr.dev`} href={service.publicUrl ?? `https://${service.domain}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors" className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }} style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
> >
https://{service.name}.containr.dev {service.publicUrl ?? `https://${service.domain}`}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@@ -590,15 +633,26 @@ export function ServiceDetailPage() {
{!isDemoMode && ( {!isDemoMode && (
<> <>
<button <button
onClick={() => deployMutation.mutate('restart')} onClick={() => stopMutation.mutate()}
disabled={deployMutation.isPending || service.status !== 'running'} disabled={stopMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`} className={`btn-stop ${service.status !== 'running' && service.status !== 'degraded' ? 'disabled' : ''}`}
> >
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/> <circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg> </svg>
STOP {stopMutation.isPending ? 'STOPPING…' : 'STOP'}
</button>
<button
onClick={() => restartMutation.mutate()}
disabled={restartMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
className={`btn-restart ${service.status !== 'running' && service.status !== 'degraded' ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
{restartMutation.isPending ? 'RESTARTING…' : 'RESTART'}
</button> </button>
<button <button
onClick={() => deployMutation.mutate('manual')} onClick={() => deployMutation.mutate('manual')}
@@ -606,10 +660,10 @@ export function ServiceDetailPage() {
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`} className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
> >
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/> <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
</svg> </svg>
RESTART {deployMutation.isPending ? 'DEPLOYING…' : 'DEPLOY'}
</button> </button>
</> </>
)} )}
@@ -1078,7 +1132,11 @@ export function ServiceDetailPage() {
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
<p className="text-sm text-[var(--text-secondary)]">Runtime values injected into the service container</p> <p className="text-sm text-[var(--text-secondary)]">
Runtime values injected at deploy. Reference another service's variable with{' '}
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1543,9 +1601,11 @@ export function ServiceDetailPage() {
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p> <p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="text-xl font-semibold text-[var(--text-primary)]"> <p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingStateQuery.data?.CurrentReplicas ?? '—'} {scalingStateQuery.data?.CurrentReplicas ??
runtimeQuery.data?.containers.filter((c) => c.state === 'running').length ??
'—'}
<span className="text-sm font-normal text-[var(--text-secondary)]"> <span className="text-sm font-normal text-[var(--text-secondary)]">
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? '—'} desired {' '}/ {scalingStateQuery.data?.DesiredReplicas ?? runtimeQuery.data?.desired ?? service?.replicas ?? '—'} desired
</span> </span>
</p> </p>
</div> </div>
@@ -1585,7 +1645,7 @@ export function ServiceDetailPage() {
</div> </div>
<button <button
type="button" type="button"
disabled={manualScaleMutation.isPending || !scaleReplicas || !scalingStateQuery.data} disabled={manualScaleMutation.isPending || !scaleReplicas}
onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))} onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
> >
@@ -1775,6 +1835,159 @@ export function ServiceDetailPage() {
</div> </div>
</div> </div>
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Networking</h3>
{networkForm === null && (
<button
type="button"
onClick={() =>
setNetworkForm({
port: service.port ? String(service.port) : '',
domain: service.domain ?? '',
healthcheckPath: service.healthcheckPath ?? '',
restartPolicy: service.restartPolicy ?? 'unless-stopped',
replicas: String(service.replicas ?? 1),
})
}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{runtimeQuery.data && (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 p-3">
<div className="flex items-center gap-4 text-xs">
<span className="text-[var(--text-tertiary)]">
Live: <span className="text-[var(--text-primary)] font-medium">{runtimeQuery.data.containers.filter((c) => c.state === 'running').length}/{runtimeQuery.data.desired}</span> replicas
</span>
<span className="text-[var(--text-tertiary)]">
Internal: <span className="mono text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
</span>
{runtimeQuery.data.urls.map((url) => (
<a key={url} href={url} target="_blank" rel="noopener noreferrer" className="mono text-[var(--accent-primary)] hover:underline">
{url}
</a>
))}
</div>
</div>
)}
{networkForm === null ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Port</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.port || '—'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Domain</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)] truncate">{service.domain || '—'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.replicas ?? 1}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Restart</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.restartPolicy || 'unless-stopped'}</p>
</div>
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label>
<input
type="number"
min={0}
max={65535}
value={networkForm.port}
onChange={(e) => setNetworkForm({ ...networkForm, port: e.target.value })}
placeholder="e.g. 3000"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Domain</label>
<input
value={networkForm.domain}
onChange={(e) => setNetworkForm({ ...networkForm, domain: e.target.value })}
placeholder="app.example.com"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Health check path</label>
<input
value={networkForm.healthcheckPath}
onChange={(e) => setNetworkForm({ ...networkForm, healthcheckPath: e.target.value })}
placeholder="/health"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Replicas</label>
<input
type="number"
min={1}
max={20}
value={networkForm.replicas}
onChange={(e) => setNetworkForm({ ...networkForm, replicas: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Restart policy</label>
<select
value={networkForm.restartPolicy}
onChange={(e) => setNetworkForm({ ...networkForm, restartPolicy: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
<option value="unless-stopped">unless-stopped</option>
<option value="always">always</option>
<option value="on-failure">on-failure</option>
<option value="no">no</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() =>
updateServiceMutation.mutate({
port: Number(networkForm.port) || 0,
domain: networkForm.domain,
healthcheck_path: networkForm.healthcheckPath,
restart_policy: networkForm.restartPolicy,
replicas: Math.max(1, Math.min(20, Number(networkForm.replicas) || 1)),
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setNetworkForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Takes effect on next deploy or redeploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && ( {!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -11,7 +11,16 @@ import {
type NodeTypes, type NodeTypes,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import {
createDeployment,
deleteService,
restartService,
startService,
stopService,
} from '@/lib/api-client';
import { useToast } from '@/shared/hooks/use-toast';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections'; import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import { import {
DEFAULT_VIEWPORT, DEFAULT_VIEWPORT,
@@ -21,7 +30,20 @@ import {
} from '../model'; } from '../model';
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage'; import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes'; import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react'; import {
Plus,
Layers,
Maximize2,
RotateCcw,
Box,
Link2,
Play,
Square,
Rocket,
Trash2,
ArrowUpRight,
Globe,
} from 'lucide-react';
type CanvasProps = { type CanvasProps = {
projectId: string; projectId: string;
@@ -46,6 +68,12 @@ const nodeTypes: NodeTypes = {
groupNode: GroupNode, groupNode: GroupNode,
}; };
type ContextMenuState = {
serviceId: string;
x: number;
y: number;
} | null;
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] { function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
const groups = metadata.groups.map( const groups = metadata.groups.map(
(group): GroupCanvasNode => ({ (group): GroupCanvasNode => ({
@@ -91,27 +119,40 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[],
return [...groups, ...serviceNodes]; return [...groups, ...serviceNodes];
} }
function toFlowEdges(links: ReturnType<typeof inferAutoConnections>): CanvasEdge[] { function toFlowEdges(
return links.map((link) => ({ links: ReturnType<typeof inferAutoConnections>,
id: link.edge.id, positionOf: (serviceId: string) => { x: number; y: number } | undefined,
source: link.edge.sourceServiceId, ): CanvasEdge[] {
target: link.edge.targetServiceId, return links.map((link) => {
animated: false, const source = positionOf(link.edge.sourceServiceId);
data: { const target = positionOf(link.edge.targetServiceId);
reasons: link.reasons, // Choose handle sides by relative position so edges flow forward
}, // instead of looping back through the canvas.
markerEnd: { const forward = !source || !target || source.x <= target.x;
type: MarkerType.ArrowClosed,
color: 'var(--accent-secondary)', return {
width: 14, id: link.edge.id,
height: 14, source: link.edge.sourceServiceId,
}, target: link.edge.targetServiceId,
style: { sourceHandle: forward ? 's-r' : 's-l',
stroke: 'var(--accent-primary)', targetHandle: forward ? 't-l' : 't-r',
strokeWidth: 2, animated: false,
}, data: {
className: 'edge-premium', reasons: link.reasons,
})); },
markerEnd: {
type: MarkerType.ArrowClosed,
color: 'var(--accent-secondary)',
width: 14,
height: 14,
},
style: {
stroke: 'var(--accent-primary)',
strokeWidth: 2,
},
className: 'edge-premium',
};
});
} }
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata { function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
@@ -157,9 +198,24 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]); const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null); const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0); const [viewportTick, setViewportTick] = useState(0);
const [envFilter, setEnvFilter] = useState<string>('all');
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
const queryClient = useQueryClient();
const toast = useToast();
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>(); const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
const environments = useMemo(() => {
const envs = new Set(services.map((service) => service.environment ?? 'production'));
return Array.from(envs).sort();
}, [services]);
const visibleServices = useMemo(
() => (envFilter === 'all' ? services : services.filter((service) => (service.environment ?? 'production') === envFilter)),
[services, envFilter],
);
const visibleIds = useMemo(() => new Set(visibleServices.map((service) => service.id)), [visibleServices]);
const serviceFingerprint = useMemo( const serviceFingerprint = useMemo(
() => services.map((service) => service.id).sort().join('|'), () => services.map((service) => service.id).sort().join('|'),
[services], [services],
@@ -168,7 +224,67 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
() => inferAutoConnections(services, variablesByService), () => inferAutoConnections(services, variablesByService),
[services, variablesByService], [services, variablesByService],
); );
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]); const edges = useMemo(() => {
const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position;
return toFlowEdges(inferredLinks, positionOf).filter(
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
);
}, [inferredLinks, visibleIds, nodes]);
const invalidateServices = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
void queryClient.invalidateQueries({ queryKey: ['service-runtime'] });
}, [projectId, queryClient]);
const actionError = (error: unknown) =>
toast.showToast(error instanceof Error ? error.message : 'Action failed', 'error');
const deployMutation = useMutation({
mutationFn: (serviceId: string) => createDeployment(serviceId, { trigger: 'manual' }),
onSuccess: () => {
toast.showToast('Deployment started', 'success');
invalidateServices();
},
onError: actionError,
});
const startMutation = useMutation({
mutationFn: (serviceId: string) => startService(serviceId),
onSuccess: () => {
toast.showToast('Service started', 'success');
invalidateServices();
},
onError: actionError,
});
const restartMutation = useMutation({
mutationFn: (serviceId: string) => restartService(serviceId),
onSuccess: () => {
toast.showToast('Service restarted', 'success');
invalidateServices();
},
onError: actionError,
});
const stopMutation = useMutation({
mutationFn: (serviceId: string) => stopService(serviceId),
onSuccess: () => {
toast.showToast('Service stopped', 'success');
invalidateServices();
},
onError: actionError,
});
const deleteMutation = useMutation({
mutationFn: (serviceId: string) => deleteService(serviceId),
onSuccess: () => {
toast.showToast('Service deleted', 'success');
invalidateServices();
},
onError: actionError,
});
const actionPending =
deployMutation.isPending ||
startMutation.isPending ||
restartMutation.isPending ||
stopMutation.isPending ||
deleteMutation.isPending;
useEffect(() => { useEffect(() => {
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null; const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
@@ -347,6 +463,35 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
]); ]);
}, [screenToFlowPosition, setNodes]); }, [screenToFlowPosition, setNodes]);
// Env filter hides nodes via React Flow's `hidden` flag — positions and
// persisted layout stay intact for services outside the current filter.
const renderedNodes = useMemo(
() =>
nodes.map((node) =>
node.type === 'serviceNode' && !visibleIds.has(node.id) ? { ...node, hidden: true } : node,
),
[nodes, visibleIds],
);
const onNodeContextMenu = useCallback(
(event: MouseEvent, node: CanvasNode) => {
if (node.type !== 'serviceNode') {
return;
}
event.preventDefault();
const bounds = wrapperRef.current?.getBoundingClientRect();
setSelectedServiceId(node.id);
const menuW = 190;
const menuH = 230;
const x = Math.min(event.clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW));
const y = Math.min(event.clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH));
setContextMenu({ serviceId: node.id, x, y });
},
[],
);
const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null;
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null; const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
const selectedServiceLinkCount = selectedService const selectedServiceLinkCount = selectedService
? inferredLinks.filter( ? inferredLinks.filter(
@@ -377,7 +522,29 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<Layers size={14} /> <Layers size={14} />
Group Group
</button> </button>
{environments.length > 1 && (
<>
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-1">
{['all', ...environments].map((env) => (
<button
key={env}
type="button"
onClick={() => setEnvFilter(env)}
className={`px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-all ${
envFilter === env
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
>
{env}
</button>
))}
</div>
</>
)}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<button <button
type="button" type="button"
@@ -411,14 +578,19 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
</div> </div>
{/* Canvas */} {/* Canvas */}
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)]"> <div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)] relative">
<ReactFlow <ReactFlow
nodes={nodes} nodes={renderedNodes}
edges={edges} edges={edges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onPaneClick={() => setSelectedServiceId(null)} onPaneClick={() => {
setSelectedServiceId(null);
setContextMenu(null);
}}
onMoveStart={() => setContextMenu(null)}
onNodeClick={(_event, node) => { onNodeClick={(_event, node) => {
setContextMenu(null);
if (node.type === 'serviceNode') { if (node.type === 'serviceNode') {
setSelectedServiceId(node.id); setSelectedServiceId(node.id);
} }
@@ -428,6 +600,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
onOpenService(node.id); onOpenService(node.id);
} }
}} }}
onNodeContextMenu={onNodeContextMenu}
onNodeDragStop={onNodeDragStop} onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)} onMoveEnd={() => setViewportTick((value) => value + 1)}
panOnDrag panOnDrag
@@ -443,6 +616,132 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
</svg> </svg>
<Background color="rgba(255,255,255,0.05)" gap={28} /> <Background color="rgba(255,255,255,0.05)" gap={28} />
</ReactFlow> </ReactFlow>
{/* Empty state */}
{services.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center pointer-events-auto">
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Rocket size={24} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
Add a service from a git repo or Docker image — it runs on a private network with the rest of this
project.
</p>
<button
type="button"
onClick={onAddService}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={15} />
Add Service
</button>
</div>
</div>
)}
{/* Node context menu */}
{contextMenu && contextService && (
<div
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] shadow-2xl py-1"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p>
</div>
<button
type="button"
onClick={() => {
setContextMenu(null);
onOpenService(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<ArrowUpRight size={13} />
Open service
</button>
{(contextService.domain || contextService.publicUrl) && (
<a
href={contextService.domain ? `https://${contextService.domain}` : contextService.publicUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<Globe size={13} />
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
</a>
)}
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
deployMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Rocket size={13} />
Deploy
</button>
{contextService.status !== 'running' && (
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
startMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Play size={13} />
Start
</button>
)}
<button
type="button"
disabled={actionPending || contextService.status !== 'running'}
onClick={() => {
setContextMenu(null);
restartMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Play size={13} />
Restart
</button>
<button
type="button"
disabled={actionPending || contextService.status !== 'running'}
onClick={() => {
setContextMenu(null);
stopMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Square size={13} />
Stop
</button>
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) {
deleteMutation.mutate(contextService.id);
}
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-50"
>
<Trash2 size={13} />
Delete
</button>
</div>
</div>
)}
</div> </div>
{/* Footer */} {/* Footer */}
@@ -465,7 +764,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
</div> </div>
) : ( ) : (
<p className="text-xs text-[var(--text-tertiary)] text-center"> <p className="text-xs text-[var(--text-tertiary)] text-center">
Click a service to select • Double-click to open • Drag to reposition • Connections auto-inferred from variables Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables
</p> </p>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client'; import { serviceStatusClass } from '@/lib/api-client';
import { Box, ArrowUpRight, Globe, Database, Terminal, MoreHorizontal, ExternalLink } from 'lucide-react'; import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
export type ServiceNodeData = { export type ServiceNodeData = {
@@ -17,40 +17,69 @@ export type GroupNodeData = {
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>; export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>; export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function serviceTypeIcon(type: string): typeof Box { function renderServiceTypeIcon(type: string, size = 18) {
switch (type) { switch (type) {
case 'web': case 'web':
return Globe; return <Globe size={size} />;
case 'database': case 'database':
return Database; return <Database size={size} />;
case 'worker': case 'worker':
return Terminal; return <Terminal size={size} />;
default: default:
return Box; return <Box size={size} />;
} }
} }
function serviceTypeColor(type: string): string { function serviceTypeColor(type: string): string {
switch (type) { switch (type) {
case 'web': case 'web':
return '#7ab8ff'; // Blue return '#7ab8ff';
case 'database': case 'database':
return '#b4e34a'; // Purple return '#b4e34a';
case 'worker': case 'worker':
return 'var(--accent-primary)'; // Pink return 'var(--accent-primary)';
default: default:
return '#9295a4'; // Gray return '#9295a4';
}
}
function statusLabel(status: string): string {
switch (status) {
case 'running':
return 'Online';
case 'degraded':
return 'Degraded';
case 'building':
return 'Deploying';
case 'failed':
return 'Failed';
default:
return 'Stopped';
}
}
function statusColor(status: string): string {
switch (status) {
case 'running':
return 'var(--success)';
case 'degraded':
case 'building':
return 'var(--warning)';
case 'failed':
return 'var(--error)';
default:
return 'var(--text-tertiary)';
} }
} }
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) { export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected, onOpen } = data; const { service, selected } = data;
const iconType = serviceTypeIcon(service.type);
const typeColor = serviceTypeColor(service.type); const typeColor = serviceTypeColor(service.type);
const isRunning = service.status === 'running'; const replicas = service.replicas ?? 0;
const stateColor = statusColor(service.status);
// Generate a mock domain for display (in real app, this would come from service data) const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const domain = `${service.name}.containr.local`; const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
return ( return (
<div <div
@@ -61,80 +90,91 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
}`} }`}
style={{ minWidth: 220, maxWidth: 280 }} style={{ minWidth: 220, maxWidth: 280 }}
> >
{/* Ambient overlay */}
<div <div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }} style={{ background: `${typeColor}10` }}
/> />
{/* Invisible connection points - required by React Flow for edge attachment */} <Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" /> <Handle id="t-r" type="target" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" /> <Handle id="s-l" type="source" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle id="s-r" type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
{/* Status indicator bar at top */} <div
<div className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${service.status === 'running' ? 'opacity-100' : 'opacity-40'}`}
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`} style={{ background: stateColor }}
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
/> />
{/* Action menu on hover */}
<button className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1.5 rounded-lg hover:bg-[var(--surface-muted)]">
<MoreHorizontal size={14} />
</button>
<div className="relative p-4"> <div className="relative p-4">
{/* Header with icon and name */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<div <div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${ className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
selected ? 'ring-2 ring-white/20' : '' selected ? 'ring-2 ring-white/20' : ''
}`} }`}
style={{ style={{
background: selected ? typeColor : `${typeColor}20`, background: selected ? typeColor : `${typeColor}20`,
color: selected ? 'white' : typeColor color: selected ? 'white' : typeColor,
}} }}
> >
{iconType === Globe && <Globe size={18} />} {renderServiceTypeIcon(service.type, 18)}
{iconType === Database && <Database size={18} />}
{iconType === Terminal && <Terminal size={18} />}
{iconType === Box && <Box size={18} />}
</div> </div>
<div className="min-w-0 flex-1 pt-0.5"> <div className="min-w-0 flex-1 pt-0.5">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4> <h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">{service.type}</p> <p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p>
</div> </div>
{replicas > 1 && (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]">
<Layers size={9} />
{replicas}
</span>
)}
</div> </div>
{/* Domain display - Railway style */} <div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr">
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" /> <ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="text-[10px] text-[var(--text-secondary)] truncate mono">{domain}</span> {publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
{internalAddr}
</span>
)}
<button
type="button"
title="Copy internal address"
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard?.writeText(internalAddr);
}}
className="ml-auto opacity-0 group-hover/addr:opacity-100 transition-opacity text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
<Copy size={10} />
</button>
</div> </div>
{/* Status and action row */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`status-dot ${serviceStatusClass(service.status)}`} /> <span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span <span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
className="text-[11px] font-semibold tracking-wide uppercase" {statusLabel(service.status)}
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
>
{isRunning ? 'Online' : service.status}
</span> </span>
</div> </div>
{service.image && (
<button <span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
type="button" {service.image}
onClick={() => onOpen(service.id)} </span>
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 ${ )}
selected
? 'bg-[var(--accent-primary)] text-[var(--accent-on)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
}`}
>
Open
<ArrowUpRight size={10} />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -147,46 +187,41 @@ export function GroupNode({ data }: NodeProps<GroupNodeType>) {
return ( return (
<div <div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden" className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
style={{ style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)', borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30' background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
{/* Ambient glow on hover */} <div
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none" className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
style={{ style={{
background: 'rgba(232,49,106,0.03)', background: 'rgba(232,49,106,0.03)',
opacity: isHovered ? 1 : 0 opacity: isHovered ? 1 : 0,
}} }}
/> />
{/* Header */}
<div className="relative p-4"> <div className="relative p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div <div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200" className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{ style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)', background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)' color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
}} }}
> >
<Box size={14} /> <Box size={14} />
</div> </div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3> <h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
</div> </div>
{isHovered && (
<button className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--surface-muted)]">
<MoreHorizontal size={14} />
</button>
)}
</div> </div>
{/* Helper text */} <p
<p className="text-[10px] text-[var(--text-tertiary)] mt-2 opacity-0 transition-opacity duration-200" style={{ opacity: isHovered ? 1 : 0 }}> className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
style={{ opacity: isHovered ? 1 : 0 }}
>
Drag services here to group them Drag services here to group them
</p> </p>
</div> </div>
+409 -3
View File
@@ -604,6 +604,216 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/services/{id}/runtime": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Service runtime state
* @description Live container state — replicas, URLs, aggregate status
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Runtime state */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
runtime?: {
desired?: number;
/** @enum {string} */
status?: "running" | "degraded" | "stopped";
urls?: string[];
containers?: {
id?: string;
name?: string;
state?: string;
replica?: number;
}[];
};
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/start": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Start service
* @description Start all stopped replicas
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service started */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/stop": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Stop service
* @description Stop all replicas without removing them
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service stopped */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/restart": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Restart service
* @description Restart all replicas
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service restarted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/redeploy": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Redeploy service
* @description Re-apply the current spec — pulls the image for image-sourced services, reuses the last deployed image otherwise
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Reconciled runtime state */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/deployments": { "/services/{id}/deployments": {
parameters: { parameters: {
query?: never; query?: never;
@@ -676,6 +886,59 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/deployments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List recent deployments
* @description Latest deployments across all services owned by the caller — feeds the dashboard deploy feed
*/
get: {
parameters: {
query?: {
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Recent deployments */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
deployments?: {
id?: string;
service_id?: string;
service_name?: string;
project_name?: string;
status?: string;
image_name?: string;
started_at?: string | null;
completed_at?: string | null;
created_at?: string;
}[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/deployments/{id}": { "/deployments/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2153,7 +2416,7 @@ export interface paths {
}; };
/** /**
* Bootstrap status * Bootstrap status
* @description Reports whether the instance already has a registered user. Drives the first-run setup gate. * @description Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate.
*/ */
get: { get: {
parameters: { parameters: {
@@ -2171,9 +2434,18 @@ export interface paths {
}; };
content: { content: {
"application/json": { "application/json": {
/** @description True when no user exists yet */ /** @description True when at least one account exists */
needs_setup?: boolean; has_users?: boolean;
user_count?: number; user_count?: number;
/**
* @description register when the instance has no users, login otherwise
* @enum {string}
*/
mode?: "register" | "login";
/** @description OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs. */
providers?: string[];
/** @description True when the owner re-opened public registration in Settings */
signup_enabled?: boolean;
}; };
}; };
}; };
@@ -2187,6 +2459,91 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/settings": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Read platform settings
* @description Admin only. Returns in-app settings that override environment variables.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Current platform settings */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlatformSettings"];
};
};
/** @description Admin access required */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
/**
* Update platform settings
* @description Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar.
*/
put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Allow new public account registration after bootstrap */
signup_enabled?: boolean;
/** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */
cloudflare_tunnel_token?: string;
};
};
};
responses: {
/** @description Updated platform settings */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlatformSettings"];
};
};
/** @description Admin access required */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users": { "/users": {
parameters: { parameters: {
query?: never; query?: never;
@@ -6687,6 +7044,21 @@ export interface components {
/** @description User display name */ /** @description User display name */
name: string; name: string;
}; };
PlatformSettings: {
/** @description Whether public account registration is open after bootstrap */
signup_enabled?: boolean;
cloudflare_tunnel?: {
/** @description Whether a tunnel token is configured (value is never returned) */
token_set?: boolean;
/**
* @description Where the active token comes from - in-app setting, env fallback, or unset
* @enum {string}
*/
source?: "app" | "env" | "none";
/** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */
container?: string;
};
};
User: { User: {
/** @description User ID */ /** @description User ID */
id?: string; id?: string;
@@ -6699,6 +7071,8 @@ export interface components {
name?: string; name?: string;
/** @description Optional avatar image URL */ /** @description Optional avatar image URL */
avatar_url?: string; avatar_url?: string;
/** @description True for the platform owner (first registered account) */
is_admin?: boolean;
/** /**
* Format: date-time * Format: date-time
* @description Account creation timestamp * @description Account creation timestamp
@@ -6817,6 +7191,18 @@ export interface components {
cpu?: string; cpu?: string;
/** @description Memory allocation */ /** @description Memory allocation */
memory?: string; memory?: string;
/** @description Desired replica count */
replicas?: number;
/** @description Container port to expose (published host port, or Traefik when domain set) */
port?: number;
/** @description Public hostname routed via Traefik */
domain?: string;
/** @description HTTP path probed on port for container health */
healthcheck_path?: string;
/** @description Docker restart policy (default unless-stopped) */
restart_policy?: string;
/** @description Computed public URL (published port or domain) */
public_url?: string;
/** /**
* Format: date-time * Format: date-time
* @description Service creation timestamp * @description Service creation timestamp
@@ -6860,6 +7246,16 @@ export interface components {
cpu?: string; cpu?: string;
/** @description Memory allocation */ /** @description Memory allocation */
memory?: string; memory?: string;
/** @description Desired replica count */
replicas?: number;
/** @description Container port to expose */
port?: number;
/** @description Public hostname routed via Traefik */
domain?: string;
/** @description HTTP path probed for container health */
healthcheck_path?: string;
/** @description Docker restart policy */
restart_policy?: string;
}; };
UpdateServiceRequest: { UpdateServiceRequest: {
/** @description Service name */ /** @description Service name */
@@ -6888,6 +7284,16 @@ export interface components {
cpu?: string; cpu?: string;
/** @description Memory allocation */ /** @description Memory allocation */
memory?: string; memory?: string;
/** @description Desired replica count */
replicas?: number;
/** @description Container port to expose */
port?: number;
/** @description Public hostname routed via Traefik */
domain?: string;
/** @description HTTP path probed for container health */
healthcheck_path?: string;
/** @description Docker restart policy */
restart_policy?: string;
}; };
Deployment: { Deployment: {
/** @description Deployment ID */ /** @description Deployment ID */
+17
View File
@@ -381,6 +381,10 @@ textarea {
animation: pulse-ring 2s ease-out infinite; animation: pulse-ring 2s ease-out infinite;
} }
.status-degraded {
background: var(--warning);
}
.status-building { .status-building {
background: var(--warning); background: var(--warning);
animation: pulse-dot 1.5s ease-in-out infinite; animation: pulse-dot 1.5s ease-in-out infinite;
@@ -979,6 +983,19 @@ textarea {
transition: all 0.3s; transition: all 0.3s;
} }
.badge-degraded {
padding: 2px 8px;
border-radius: 6px;
background: rgba(242, 201, 76, 0.15);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.7px;
color: var(--warning);
text-transform: uppercase;
display: inline-flex;
align-items: center;
}
/* Badge Stopped - self.html exact match */ /* Badge Stopped - self.html exact match */
.badge-stopped { .badge-stopped {
padding: 2px 8px; padding: 2px 8px;
+151 -2
View File
@@ -1,6 +1,6 @@
import type { components, paths } from '@/generated/api-types'; import type { components, paths } from '@/generated/api-types';
export type ServiceStatus = 'running' | 'stopped' | 'building' | 'failed' | 'unknown'; export type ServiceStatus = 'running' | 'degraded' | 'stopped' | 'building' | 'failed' | 'unknown';
export type ProjectStats = components['schemas']['ProjectStats']; export type ProjectStats = components['schemas']['ProjectStats'];
@@ -18,10 +18,20 @@ export type UserProfile = {
email: string; email: string;
name: string; name: string;
avatarUrl?: string; avatarUrl?: string;
isAdmin: boolean;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
}; };
export type PlatformSettings = {
signupEnabled: boolean;
cloudflareTunnel: {
tokenSet: boolean;
source: 'app' | 'env' | 'none';
container: string;
};
};
export type ServiceEntity = { export type ServiceEntity = {
id: string; id: string;
projectId: string; projectId: string;
@@ -38,6 +48,26 @@ export type ServiceEntity = {
buildPath?: string; buildPath?: string;
cpu?: string; cpu?: string;
memory?: string; memory?: string;
replicas?: number;
port?: number;
domain?: string;
healthcheckPath?: string;
restartPolicy?: string;
publicUrl?: string;
};
export type RuntimeContainer = {
id: string;
name: string;
state: string;
replica: number;
};
export type ServiceRuntime = {
desired: number;
status: 'running' | 'degraded' | 'stopped';
urls: string[];
containers: RuntimeContainer[];
}; };
export type ServiceVariable = { export type ServiceVariable = {
@@ -333,6 +363,7 @@ type RawNodeAgent = components['schemas']['NodeAgent'];
export type CreateProjectInput = components['schemas']['CreateProjectRequest']; export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
export type CreateServiceInput = components['schemas']['CreateServiceRequest']; export type CreateServiceInput = components['schemas']['CreateServiceRequest'];
export type UpdateServiceInput = components['schemas']['UpdateServiceRequest'];
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number; readonly status: number;
@@ -348,7 +379,8 @@ type ProjectsResponse200 = paths['/projects']['get']['responses'][200]['content'
type JsonLike = Record<string, unknown>; type JsonLike = Record<string, unknown>;
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082'; const configuredBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
const rawBase = configuredBase || window.location.origin;
const normalizedBase = rawBase.replace(/\/$/, ''); const normalizedBase = rawBase.replace(/\/$/, '');
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, ''); const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`; const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
@@ -441,11 +473,46 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
email: profile.email, email: profile.email,
name: profile.name, name: profile.name,
avatarUrl: profile.avatar_url ?? undefined, avatarUrl: profile.avatar_url ?? undefined,
isAdmin: profile.is_admin ?? false,
createdAt: profile.created_at ?? undefined, createdAt: profile.created_at ?? undefined,
updatedAt: profile.updated_at ?? undefined, updatedAt: profile.updated_at ?? undefined,
}; };
} }
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
return {
signupEnabled: raw.signup_enabled ?? false,
cloudflareTunnel: {
tokenSet: raw.cloudflare_tunnel?.token_set ?? false,
source: raw.cloudflare_tunnel?.source ?? 'none',
container: raw.cloudflare_tunnel?.container ?? 'missing',
},
};
}
export async function getPlatformSettings(): Promise<PlatformSettings> {
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings');
return normalizePlatformSettings(raw);
}
export async function updatePlatformSettings(input: {
signupEnabled?: boolean;
cloudflareTunnelToken?: string;
}): Promise<PlatformSettings> {
const body: Record<string, unknown> = {};
if (input.signupEnabled !== undefined) {
body.signup_enabled = input.signupEnabled;
}
if (input.cloudflareTunnelToken !== undefined) {
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
}
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
method: 'PUT',
body: JSON.stringify(body),
});
return normalizePlatformSettings(raw);
}
function normalizeService(service: RawService): ServiceEntity | null { function normalizeService(service: RawService): ServiceEntity | null {
if (!service.id || !service.name || !service.project_id) { if (!service.id || !service.name || !service.project_id) {
return null; return null;
@@ -469,6 +536,12 @@ function normalizeService(service: RawService): ServiceEntity | null {
buildPath: service.build_path, buildPath: service.build_path,
cpu: service.cpu, cpu: service.cpu,
memory: service.memory, memory: service.memory,
replicas: service.replicas,
port: service.port,
domain: service.domain,
healthcheckPath: service.healthcheck_path,
restartPolicy: service.restart_policy,
publicUrl: service.public_url,
}; };
} }
@@ -952,12 +1025,55 @@ export async function createService(projectId: string, input: CreateServiceInput
return parsed; return parsed;
} }
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
method: 'PUT',
body: JSON.stringify(input),
});
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
const parsed = normalizeService(row);
if (!parsed) {
throw new ApiError('Update service response is invalid', 500);
}
return parsed;
}
export async function deleteService(serviceId: string): Promise<void> { export async function deleteService(serviceId: string): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}`, { await requestJson<{ message?: string }>(`/services/${serviceId}`, {
method: 'DELETE', method: 'DELETE',
}); });
} }
export async function getServiceRuntime(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/runtime`);
return (
payload.runtime ?? {
desired: 0,
status: 'stopped',
urls: [],
containers: [],
}
);
}
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
}
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
method: 'POST',
});
return payload.runtime ?? { desired: 0, status: 'stopped', urls: [], containers: [] };
}
function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] { function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
const result: ServiceVariable[] = []; const result: ServiceVariable[] = [];
for (const row of rows ?? []) { for (const row of rows ?? []) {
@@ -1030,6 +1146,37 @@ export async function listDeployments(serviceId: string): Promise<DeploymentEnti
return normalizeDeploymentArray(payload.deployments); return normalizeDeploymentArray(payload.deployments);
} }
export type RecentDeploymentEntity = {
id: string;
serviceId: string;
serviceName: string;
projectName: string;
status: string;
imageName?: string;
startedAt?: string;
completedAt?: string;
createdAt?: string;
};
export async function listRecentDeployments(limit = 10): Promise<RecentDeploymentEntity[]> {
const payload = await requestJson<{ deployments?: Array<Record<string, unknown>> }>(
`/deployments?limit=${limit}`,
);
return (payload.deployments ?? [])
.map((d) => ({
id: String(d.id ?? ''),
serviceId: String(d.service_id ?? ''),
serviceName: String(d.service_name ?? ''),
projectName: String(d.project_name ?? ''),
status: String(d.status ?? ''),
imageName: d.image_name ? String(d.image_name) : undefined,
startedAt: d.started_at ? String(d.started_at) : undefined,
completedAt: d.completed_at ? String(d.completed_at) : undefined,
createdAt: d.created_at ? String(d.created_at) : undefined,
}))
.filter((d) => d.id !== '');
}
export async function createDeployment( export async function createDeployment(
serviceId: string, serviceId: string,
input: CreateDeploymentInput = {}, input: CreateDeploymentInput = {},
@@ -1203,6 +1350,8 @@ export function serviceStatusClass(status: ServiceStatus): string {
switch (status) { switch (status) {
case 'running': case 'running':
return 'status-running'; return 'status-running';
case 'degraded':
return 'status-degraded';
case 'building': case 'building':
return 'status-building'; return 'status-building';
case 'failed': case 'failed':
+12 -3
View File
@@ -20,6 +20,8 @@ export type AuthBootstrap = {
hasUsers: boolean; hasUsers: boolean;
userCount: number; userCount: number;
mode: 'register' | 'login'; mode: 'register' | 'login';
providers: string[];
signupEnabled: boolean;
}; };
export class AuthError extends Error { export class AuthError extends Error {
@@ -32,15 +34,16 @@ export class AuthError extends Error {
} }
} }
const rawAuthBase = (import.meta.env.VITE_AUTH_URL as string | undefined) ?? 'http://localhost:8082/api/auth'; const configuredAuthBase = ((import.meta.env.VITE_AUTH_URL as string | undefined) ?? '').trim();
const AUTH_BASE = rawAuthBase.replace(/\/$/, ''); const AUTH_BASE = (configuredAuthBase || `${window.location.origin}/api/auth`).replace(/\/$/, '');
export function getAuthBaseUrl(): string { export function getAuthBaseUrl(): string {
return AUTH_BASE; return AUTH_BASE;
} }
export async function getAuthBootstrap(): Promise<AuthBootstrap> { export async function getAuthBootstrap(): Promise<AuthBootstrap> {
const apiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082').replace(/\/$/, ''); const configuredApiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
const apiBase = (configuredApiBase || window.location.origin).replace(/\/$/, '');
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`; const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, { const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
method: 'GET', method: 'GET',
@@ -56,10 +59,16 @@ export async function getAuthBootstrap(): Promise<AuthBootstrap> {
throw new AuthError(message, response.status); throw new AuthError(message, response.status);
} }
const providers = Array.isArray(payload?.providers)
? payload.providers.filter((provider): provider is string => typeof provider === 'string')
: [];
return { return {
hasUsers: Boolean(payload?.has_users), hasUsers: Boolean(payload?.has_users),
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0, userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
mode: payload?.mode === 'register' ? 'register' : 'login', mode: payload?.mode === 'register' ? 'register' : 'login',
providers,
signupEnabled: Boolean(payload?.signup_enabled),
}; };
} }
+8
View File
@@ -54,6 +54,14 @@ export default defineConfig({
fs: { fs: {
// Allow serving files from project root // Allow serving files from project root
allow: ['..'] allow: ['..']
},
proxy: {
// Same-origin API path for dev - matches the nginx proxy in the shipped image
'/api': {
target: 'http://localhost:8082',
changeOrigin: true,
ws: true,
},
} }
}, },
// Preview server optimization // Preview server optimization
+24 -11
View File
@@ -1,5 +1,3 @@
name: containr
services: services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
@@ -18,13 +16,17 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
redis: dragonfly:
image: redis:7-alpine image: docker.dragonflydb.io/dragonflydb/dragonfly:${DRAGONFLY_VERSION:-latest}
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123} command:
- --requirepass=${REDIS_PASSWORD:-dev_redis_123}
- --proactor_threads=${DRAGONFLY_THREADS:-1}
- --maxmemory=${DRAGONFLY_MAXMEMORY:-256mb}
- --dir=/data
ports: ports:
- "${REDIS_PORT:-6379}:6379" - "${REDIS_PORT:-6379}:6379"
volumes: volumes:
- redis_data:/data - dragonfly_data:/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-dev_redis_123} ping | grep PONG"] test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-dev_redis_123} ping | grep PONG"]
interval: 10s interval: 10s
@@ -33,6 +35,8 @@ services:
restart: unless-stopped restart: unless-stopped
backend: backend:
image: ghcr.io/dvorinka/containr-backend:${CONTAINR_VERSION:-latest}
pull_policy: missing
build: build:
context: ./app/backend context: ./app/backend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -40,7 +44,7 @@ services:
- "${API_PORT:-8082}:8080" - "${API_PORT:-8082}:8080"
environment: environment:
DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@dragonfly:6379/0
PORT: 8080 PORT: 8080
HOST: 0.0.0.0 HOST: 0.0.0.0
ENVIRONMENT: ${ENVIRONMENT:-development} ENVIRONMENT: ${ENVIRONMENT:-development}
@@ -80,10 +84,17 @@ services:
BITBUCKET_API_URL: ${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0} BITBUCKET_API_URL: ${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
BITBUCKET_BASE_URL: ${BITBUCKET_BASE_URL:-https://bitbucket.org} BITBUCKET_BASE_URL: ${BITBUCKET_BASE_URL:-https://bitbucket.org}
GITEA_BASE_URL: ${GITEA_BASE_URL:-https://gitea.example.com} GITEA_BASE_URL: ${GITEA_BASE_URL:-https://gitea.example.com}
volumes:
# Required for service deployments and managed sidecars (e.g. cloudflared)
- /var/run/docker.sock:/var/run/docker.sock
# Grants the non-root backend user access to the Docker socket.
# install.sh writes the host's docker gid into DOCKER_GID.
group_add:
- "${DOCKER_GID:-0}"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: dragonfly:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
@@ -94,12 +105,14 @@ services:
start_period: 30s start_period: 30s
frontend: frontend:
image: ghcr.io/dvorinka/containr-frontend:${CONTAINR_VERSION:-latest}
pull_policy: missing
build: build:
context: ./app/frontend context: ./app/frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8082} VITE_API_URL: ${VITE_API_URL:-}
VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth} VITE_AUTH_URL: ${VITE_AUTH_URL:-}
ports: ports:
- "${HTTP_PORT:-3000}:80" - "${HTTP_PORT:-3000}:80"
depends_on: depends_on:
@@ -109,4 +122,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: dragonfly_data:
+303 -3
View File
@@ -424,6 +424,126 @@ paths:
'204': '204':
description: Service deleted successfully description: Service deleted successfully
/services/{id}/runtime:
get:
tags:
- Services
summary: Service runtime state
description: Live container state — replicas, URLs, aggregate status
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Runtime state
content:
application/json:
schema:
type: object
properties:
runtime:
type: object
properties:
desired:
type: integer
status:
type: string
enum: [running, degraded, stopped]
urls:
type: array
items:
type: string
containers:
type: array
items:
type: object
properties:
id:
type: string
name:
type: string
state:
type: string
replica:
type: integer
/services/{id}/start:
post:
tags:
- Services
summary: Start service
description: Start all stopped replicas
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Service started
/services/{id}/stop:
post:
tags:
- Services
summary: Stop service
description: Stop all replicas without removing them
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Service stopped
/services/{id}/restart:
post:
tags:
- Services
summary: Restart service
description: Restart all replicas
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Service restarted
/services/{id}/redeploy:
post:
tags:
- Services
summary: Redeploy service
description: Re-apply the current spec — pulls the image for image-sourced services, reuses the last deployed image otherwise
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Reconciled runtime state
/services/{id}/deployments: /services/{id}/deployments:
get: get:
tags: tags:
@@ -475,6 +595,56 @@ paths:
schema: schema:
$ref: '#/components/schemas/Deployment' $ref: '#/components/schemas/Deployment'
/deployments:
get:
tags:
- Deployments
summary: List recent deployments
description: Latest deployments across all services owned by the caller — feeds the dashboard deploy feed
security:
- bearerAuth: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
default: 10
maximum: 50
responses:
'200':
description: Recent deployments
content:
application/json:
schema:
type: object
properties:
deployments:
type: array
items:
type: object
properties:
id:
type: string
service_id:
type: string
service_name:
type: string
project_name:
type: string
status:
type: string
image_name:
type: string
started_at:
type: string
nullable: true
completed_at:
type: string
nullable: true
created_at:
type: string
/deployments/{id}: /deployments/{id}:
get: get:
tags: tags:
@@ -1432,7 +1602,7 @@ paths:
tags: tags:
- Authentication - Authentication
summary: Bootstrap status summary: Bootstrap status
description: Reports whether the instance already has a registered user. Drives the first-run setup gate. description: Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate.
responses: responses:
'200': '200':
description: Bootstrap state description: Bootstrap state
@@ -1441,11 +1611,70 @@ paths:
schema: schema:
type: object type: object
properties: properties:
needs_setup: has_users:
type: boolean type: boolean
description: True when no user exists yet description: True when at least one account exists
user_count: user_count:
type: integer type: integer
mode:
type: string
enum: [register, login]
description: register when the instance has no users, login otherwise
providers:
type: array
items:
type: string
description: OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs.
signup_enabled:
type: boolean
description: True when the owner re-opened public registration in Settings
/settings:
get:
tags:
- Settings
summary: Read platform settings
description: Admin only. Returns in-app settings that override environment variables.
security:
- bearerAuth: []
responses:
'200':
description: Current platform settings
content:
application/json:
schema:
$ref: '#/components/schemas/PlatformSettings'
'403':
description: Admin access required
put:
tags:
- Settings
summary: Update platform settings
description: Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
signup_enabled:
type: boolean
description: Allow new public account registration after bootstrap
cloudflare_tunnel_token:
type: string
description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container.
responses:
'200':
description: Updated platform settings
content:
application/json:
schema:
$ref: '#/components/schemas/PlatformSettings'
'403':
description: Admin access required
# User Management # User Management
/users: /users:
@@ -4111,6 +4340,26 @@ components:
type: string type: string
description: User display name description: User display name
PlatformSettings:
type: object
properties:
signup_enabled:
type: boolean
description: Whether public account registration is open after bootstrap
cloudflare_tunnel:
type: object
properties:
token_set:
type: boolean
description: Whether a tunnel token is configured (value is never returned)
source:
type: string
enum: [app, env, none]
description: Where the active token comes from - in-app setting, env fallback, or unset
container:
type: string
description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable
User: User:
type: object type: object
properties: properties:
@@ -4127,6 +4376,9 @@ components:
avatar_url: avatar_url:
type: string type: string
description: Optional avatar image URL description: Optional avatar image URL
is_admin:
type: boolean
description: True for the platform owner (first registered account)
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -4290,6 +4542,24 @@ components:
memory: memory:
type: string type: string
description: Memory allocation description: Memory allocation
replicas:
type: integer
description: Desired replica count
port:
type: integer
description: Container port to expose (published host port, or Traefik when domain set)
domain:
type: string
description: Public hostname routed via Traefik
healthcheck_path:
type: string
description: HTTP path probed on port for container health
restart_policy:
type: string
description: Docker restart policy (default unless-stopped)
public_url:
type: string
description: Computed public URL (published port or domain)
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -4342,6 +4612,21 @@ components:
memory: memory:
type: string type: string
description: Memory allocation description: Memory allocation
replicas:
type: integer
description: Desired replica count
port:
type: integer
description: Container port to expose
domain:
type: string
description: Public hostname routed via Traefik
healthcheck_path:
type: string
description: HTTP path probed for container health
restart_policy:
type: string
description: Docker restart policy
UpdateServiceRequest: UpdateServiceRequest:
type: object type: object
@@ -4378,6 +4663,21 @@ components:
memory: memory:
type: string type: string
description: Memory allocation description: Memory allocation
replicas:
type: integer
description: Desired replica count
port:
type: integer
description: Container port to expose
domain:
type: string
description: Public hostname routed via Traefik
healthcheck_path:
type: string
description: HTTP path probed for container health
restart_policy:
type: string
description: Docker restart policy
Deployment: Deployment:
type: object type: object
+4 -4
View File
@@ -68,7 +68,7 @@ Internet → Traefik (Port 80/443)
├── Frontend (React/Nginx) ├── Frontend (React/Nginx)
├── Backend (Go API) ├── Backend (Go API)
├── PostgreSQL (Database) ├── PostgreSQL (Database)
└── Redis (Cache) └── Dragonfly (Cache)
``` ```
## Configuration Files ## Configuration Files
@@ -138,8 +138,8 @@ docker-compose up -d --force-recreate
# Backup PostgreSQL # Backup PostgreSQL
docker-compose exec postgres pg_dump -U containr_user containr > backup.sql docker-compose exec postgres pg_dump -U containr_user containr > backup.sql
# Backup Redis # Backup Dragonfly (snapshot)
docker-compose exec redis redis-cli --rdb /data/dump.rdb docker-compose exec dragonfly redis-cli -a "$REDIS_PASSWORD" save
``` ```
### SSL Certificates ### SSL Certificates
@@ -211,7 +211,7 @@ docker-compose up -d
### Performance Tuning ### Performance Tuning
1. **Nginx Caching** - Already configured in `nginx.conf` 1. **Nginx Caching** - Already configured in `nginx.conf`
2. **Redis Caching** - Configure in your application 2. **Dragonfly Caching** - Configure in your application
3. **Database Pooling** - Adjust connection limits in Go app 3. **Database Pooling** - Adjust connection limits in Go app
## Production Tips ## Production Tips
+1 -1
View File
@@ -5,7 +5,7 @@
### Prerequisites ### Prerequisites
- Go 1.21+ - Go 1.21+
- PostgreSQL 12+ - PostgreSQL 12+
- Redis (optional) - Dragonfly or Redis-compatible cache (optional)
### Environment Variables ### Environment Variables
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+18 -10
View File
@@ -65,12 +65,16 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
redis: dragonfly:
image: redis:7-alpine image: docker.dragonflydb.io/dragonflydb/dragonfly:${DRAGONFLY_VERSION:-latest}
container_name: containr-redis container_name: containr-dragonfly
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123} command:
- --requirepass=${REDIS_PASSWORD:-dev_redis_123}
- --proactor_threads=${DRAGONFLY_THREADS:-1}
- --maxmemory=${DRAGONFLY_MAXMEMORY:-256mb}
- --dir=/data
volumes: volumes:
- redis_data:/data - dragonfly_data:/data
networks: networks:
- containr-network - containr-network
restart: unless-stopped restart: unless-stopped
@@ -89,7 +93,7 @@ services:
- "8082:8080" - "8082:8080"
environment: environment:
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable - DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0 - REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@dragonfly:6379/0
- PORT=8080 - PORT=8080
- HOST=0.0.0.0 - HOST=0.0.0.0
- ENVIRONMENT=${ENVIRONMENT:-production} - ENVIRONMENT=${ENVIRONMENT:-production}
@@ -130,12 +134,16 @@ services:
- BITBUCKET_API_URL=${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0} - BITBUCKET_API_URL=${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
- BITBUCKET_BASE_URL=${BITBUCKET_BASE_URL:-https://bitbucket.org} - BITBUCKET_BASE_URL=${BITBUCKET_BASE_URL:-https://bitbucket.org}
- GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com} - GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-0}"
networks: networks:
- containr-network - containr-network
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: dragonfly:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
@@ -160,8 +168,8 @@ services:
context: ../app/frontend context: ../app/frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8082} VITE_API_URL: ${VITE_API_URL:-}
VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth} VITE_AUTH_URL: ${VITE_AUTH_URL:-}
container_name: containr-frontend container_name: containr-frontend
ports: ports:
- "3000:80" - "3000:80"
@@ -203,7 +211,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
redis_data: dragonfly_data:
driver: local driver: local
networks: networks:
+82 -9
View File
@@ -1,19 +1,62 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Containr one-shot installer: creates .env with generated secrets, builds # Containr installer. Works from a source checkout, or standalone:
# images, boots the stack, and waits for the API to report healthy. # curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | bash
#
# Creates .env with generated secrets, pulls the published images (or builds
# from source), boots the stack, and waits for the API to report healthy.
#
# Env overrides (all optional):
# CONTAINR_DIR install dir when not run from a clone (default ./containr)
# CONTAINR_VERSION image tag to pull (default latest, e.g. 0.1.1)
# CONTAINR_REF git ref for downloaded files (default main)
# CONTAINR_BUILD=1 force building images from source
# HTTP_PORT API_PORT POSTGRES_PORT REDIS_PORT host port remaps
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")" 2>/dev/null || true
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
err() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; } err() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; }
CONTAINR_REF="${CONTAINR_REF:-main}"
RAW_BASE="https://raw.githubusercontent.com/Dvorinka/Containr/${CONTAINR_REF}"
# Dependencies # Dependencies
command -v docker >/dev/null 2>&1 || { err "docker not found. Install Docker: https://docs.docker.com/get-docker/"; exit 1; } command -v docker >/dev/null 2>&1 || { err "docker not found. Install Docker: https://docs.docker.com/get-docker/"; exit 1; }
docker compose version >/dev/null 2>&1 || { err "docker compose (v2) not found. Update Docker or install the compose plugin."; exit 1; } docker compose version >/dev/null 2>&1 || { err "docker compose (v2) not found. Update Docker or install the compose plugin."; exit 1; }
docker info >/dev/null 2>&1 || { err "docker daemon is not running or not reachable."; exit 1; } docker info >/dev/null 2>&1 || { err "docker daemon is not running or not reachable."; exit 1; }
command -v openssl >/dev/null 2>&1 || { err "openssl not found (needed to generate secrets)."; exit 1; } command -v openssl >/dev/null 2>&1 || { err "openssl not found (needed to generate secrets)."; exit 1; }
command -v curl >/dev/null 2>&1 || { err "curl not found (needed for the health check)."; exit 1; } command -v curl >/dev/null 2>&1 || { err "curl not found (needed for downloads and the health check)."; exit 1; }
# Install location: explicit CONTAINR_DIR wins; otherwise reuse the current
# dir when it already looks like a checkout/install, else ./containr.
if [[ -n "${CONTAINR_DIR:-}" ]]; then
mkdir -p "$CONTAINR_DIR"
cd "$CONTAINR_DIR"
elif [[ ! -f docker-compose.yml || ! -f .env.example ]]; then
CONTAINR_DIR="./containr"
mkdir -p "$CONTAINR_DIR"
cd "$CONTAINR_DIR"
fi
# Fetch compose file and env template when running standalone. Existing
# files are kept so re-runs stay idempotent.
if [[ ! -f docker-compose.yml ]]; then
info "Downloading docker-compose.yml (${CONTAINR_REF})..."
curl -fsSL "$RAW_BASE/docker-compose.yml" -o docker-compose.yml
fi
if [[ ! -f .env.example ]]; then
info "Downloading .env.example (${CONTAINR_REF})..."
curl -fsSL "$RAW_BASE/.env.example" -o .env.example
fi
# Source tarball, only needed when building images locally.
ensure_source() {
[[ -d app/backend ]] && return 0
command -v tar >/dev/null 2>&1 || { err "tar not found (needed to unpack the source)."; exit 1; }
info "Downloading source (${CONTAINR_REF})..."
curl -fsSL "https://codeload.github.com/Dvorinka/Containr/tar.gz/${CONTAINR_REF}" | tar xz --strip-components=1
}
# Env file # Env file
if [[ -f .env ]]; then if [[ -f .env ]]; then
@@ -28,6 +71,7 @@ else
} }
PG_PASS="$(gen)"; REDIS_PASS="$(gen)" PG_PASS="$(gen)"; REDIS_PASS="$(gen)"
setvar ENVIRONMENT "production"
setvar JWT_SECRET "$(gen)" setvar JWT_SECRET "$(gen)"
setvar BETTER_AUTH_SECRET "$(gen)" setvar BETTER_AUTH_SECRET "$(gen)"
setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)" setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)"
@@ -37,17 +81,46 @@ else
# POSTGRES_PASSWORD, DATABASE_URL and DB_PASSWORD stay in sync. # POSTGRES_PASSWORD, DATABASE_URL and DB_PASSWORD stay in sync.
sed -i.bak "s|your_secure_postgres_password|$PG_PASS|g" .env sed -i.bak "s|your_secure_postgres_password|$PG_PASS|g" .env
sed -i.bak "s|your_secure_redis_password|$REDIS_PASS|g" .env sed -i.bak "s|your_secure_redis_password|$REDIS_PASS|g" .env
# Docker socket gid as seen INSIDE containers (modern Docker maps it to
# root). Probe a throwaway container; fall back to the host gid, then 0.
if [[ -z "${DOCKER_GID:-}" ]]; then
DOCKER_GID="$(docker run --rm -v /var/run/docker.sock:/var/run/docker.sock alpine:3 stat -c %g /var/run/docker.sock 2>/dev/null \
|| stat -c %g /var/run/docker.sock 2>/dev/null \
|| stat -f %g /var/run/docker.sock 2>/dev/null \
|| echo 0)"
fi
setvar DOCKER_GID "$DOCKER_GID"
# Persist any overrides passed via the environment.
for v in HTTP_PORT API_PORT POSTGRES_PORT REDIS_PORT CONTAINR_VERSION; do
[[ -n "${!v:-}" ]] && setvar "$v" "${!v}"
done
rm -f .env.bak rm -f .env.bak
info ".env created with random secrets." info ".env created with random secrets."
fi fi
# Ports (overridable via environment, matching docker-compose.yml vars) # Ports for the health wait: environment first, then .env, then defaults.
HTTP_PORT="${HTTP_PORT:-$(grep -E '^HTTP_PORT=' .env | cut -d= -f2)}"
API_PORT="${API_PORT:-$(grep -E '^API_PORT=' .env | cut -d= -f2)}"
HTTP_PORT="${HTTP_PORT:-3000}" HTTP_PORT="${HTTP_PORT:-3000}"
API_PORT="${API_PORT:-8082}" API_PORT="${API_PORT:-8082}"
# Build and boot # Boot: pull published images by default (CONTAINR_VERSION pins a release).
info "Building images and starting the stack..." # Build from source with CONTAINR_BUILD=1, or as fallback when the registry
docker compose up -d --build # is unreachable.
if [[ "${CONTAINR_BUILD:-}" == "1" ]]; then
info "CONTAINR_BUILD=1 - building images from source..."
ensure_source
docker compose build
elif ! docker compose pull; then
info "Image pull failed - building from source instead..."
ensure_source
docker compose build
fi
info "Starting the stack..."
docker compose up -d
# Wait for the API # Wait for the API
info "Waiting for the API on http://localhost:${API_PORT}/health ..." info "Waiting for the API on http://localhost:${API_PORT}/health ..."
@@ -69,4 +142,4 @@ info "Containr is running."
echo " UI: http://localhost:${HTTP_PORT}" echo " UI: http://localhost:${HTTP_PORT}"
echo " API: http://localhost:${API_PORT}" echo " API: http://localhost:${API_PORT}"
echo "" echo ""
echo "Manage with: docker compose logs -f | docker compose down" echo "Manage with: (cd ${CONTAINR_DIR:-.} && docker compose logs -f | docker compose down)"